Compare commits
28 Commits
main
...
feature/login
| Author | SHA1 | Date | |
|---|---|---|---|
| 63764d0718 | |||
| af529a4e81 | |||
| 7a964785c7 | |||
| d4dbf849af | |||
| 1e050233b9 | |||
| e163d2f349 | |||
| 618dcef37f | |||
| e1fd70071b | |||
| 64027eb8f2 | |||
| 11a6568717 | |||
| b1beed4ae6 | |||
| d8f0e48bf3 | |||
| 1943f23406 | |||
| f594a45e84 | |||
| 89790539b7 | |||
| c993f848cb | |||
| 83a529b4c5 | |||
| 5565d15be5 | |||
| 66d0caa588 | |||
| fde9105834 | |||
| 7bdea785f9 | |||
| f1858f8f0f | |||
| 4e4d03d62a | |||
| d99541b7e4 | |||
| 8cbdd93e56 | |||
| 96eac88f36 | |||
| c5016489ac | |||
| 8a77324f89 |
@@ -4,7 +4,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_LOCALE=vi
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:93hwWdQyCR3816s/FI231k5H909nxIHcTUeomm7WXVA=
|
||||
APP_DEBUG=true
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=:memory:
|
||||
|
||||
CACHE_STORE=array
|
||||
SESSION_DRIVER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
@@ -52,3 +52,5 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.tmp
|
||||
*.temp
|
||||
Tasks/
|
||||
**/.env.app
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ResetCardsCommand extends Command
|
||||
{
|
||||
protected $signature = 'cards:reset';
|
||||
|
||||
protected $description = 'Reset all system cards to 0 at the beginning of the month';
|
||||
|
||||
public function __construct(
|
||||
private AdminServiceInterface $adminService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->adminService->resetAllCards();
|
||||
$this->info('Successfully reset all cards for active members to 0!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AdminServiceInterface $adminService
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||
$search = $request->input('search');
|
||||
$flagSend = $request->input('flag_send');
|
||||
|
||||
['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser]
|
||||
= $this->adminService->getUserListWithStats($selectedMonth, $search, $flagSend);
|
||||
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
return response()->json([
|
||||
'title' => view('admin.users.partials.title', compact('selectedMonth'))->render(),
|
||||
'stats' => view('admin.users.partials.stats', compact('topReceivedUser', 'topSentUser'))->render(),
|
||||
'table' => view('admin.users.partials.table', compact('users'))->render(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser', 'search', 'flagSend'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('admin.users.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'msnv' => 'required|integer|unique:user,msnv',
|
||||
'name' => 'required|string|max:255',
|
||||
'mail' => 'required|email|unique:user,mail',
|
||||
'departments' => 'required|integer|min:1|max:5',
|
||||
'password' => 'required|string',
|
||||
'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN,
|
||||
]);
|
||||
|
||||
$this->adminService->createUser($request->only('msnv', 'name', 'mail', 'departments', 'password', 'role'));
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', __('messages.user_create_success'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, $msnv)
|
||||
{
|
||||
$user = User::where('msnv', $msnv)->firstOrFail();
|
||||
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||
|
||||
$administrations = $this->adminService->getUserTransactions($msnv, $selectedMonth);
|
||||
|
||||
return view('admin.users.edit', compact('user', 'administrations', 'selectedMonth'));
|
||||
}
|
||||
|
||||
public function update(Request $request, $msnv)
|
||||
{
|
||||
$request->validate([
|
||||
'num_card' => 'nullable|integer|min:1',
|
||||
'flag_send' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$this->adminService->updateUser($msnv, $request->only('num_card', 'flag_send'));
|
||||
|
||||
return redirect()->back()->with('success', __('messages.user_update_success'));
|
||||
}
|
||||
|
||||
public function destroy($msnv)
|
||||
{
|
||||
$this->adminService->deactivateUser($msnv);
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', __('messages.user_deactivate_success'));
|
||||
}
|
||||
|
||||
public function resetCards()
|
||||
{
|
||||
$this->adminService->resetAllCards();
|
||||
|
||||
return redirect()->back()->with('success', __('messages.cards_reset_success'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Auth\Contracts\AuthServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AuthServiceInterface $authService
|
||||
) {}
|
||||
|
||||
public function showLoginForm()
|
||||
{
|
||||
if (Auth::check() && !session('dialog_first_login')) {
|
||||
return redirect($this->authService->getRedirectRouteForUser(Auth::user()));
|
||||
}
|
||||
return view('login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
// Custom validation check: treat all validation errors as "Invalid email/password"
|
||||
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
|
||||
'mail' => 'required|email|max:255',
|
||||
'password' => 'required|string|max:100',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return back()->withInput($request->only('mail'))->withErrors([
|
||||
'mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại',
|
||||
]);
|
||||
}
|
||||
|
||||
$credentials = $validator->validated();
|
||||
$email = \Illuminate\Support\Str::lower($credentials['mail']);
|
||||
$throttleKey = 'login-attempts:' . $email;
|
||||
|
||||
// Brute-force check (lock after 5 failed attempts)
|
||||
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($throttleKey, 5)) {
|
||||
return back()->withInput($request->only('mail'))->with('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.');
|
||||
}
|
||||
|
||||
$result = $this->authService->attemptLogin($credentials, $request);
|
||||
|
||||
if (!$result['success']) {
|
||||
if ($result['error_key'] === 'permission') {
|
||||
return back()->withInput($request->only('mail'))->with('dialog_error_permission', $result['error_msg']);
|
||||
}
|
||||
|
||||
// For invalid password / email (counts towards brute-force attempts)
|
||||
\Illuminate\Support\Facades\RateLimiter::hit($throttleKey, 900); // 15 minutes lockout
|
||||
|
||||
return back()->withInput($request->only('mail'))->withErrors([
|
||||
'mail' => $result['error_msg'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Clear rate limiter on success
|
||||
\Illuminate\Support\Facades\RateLimiter::clear($throttleKey);
|
||||
|
||||
$user = $result['user'];
|
||||
if ($user->first_login == \App\Models\User::FIRST_LOGIN_TRUE) {
|
||||
return redirect()->route('login')->with('dialog_first_login', true);
|
||||
}
|
||||
|
||||
return redirect($this->authService->getRedirectRouteForUser($user));
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$this->authService->logout($request);
|
||||
return redirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Administration;
|
||||
use App\Services\User\Contracts\UserServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private UserServiceInterface $userService
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||
$user = Auth::user();
|
||||
|
||||
$administrations = $this->userService->getDashboard($user, $selectedMonth);
|
||||
$stats = $this->userService->getPersonalStats($user, $selectedMonth);
|
||||
|
||||
$receivedRanking = $this->userService->getRankingList('received', $selectedMonth, $user);
|
||||
$sentRanking = $this->userService->getRankingList('sent', $selectedMonth, $user);
|
||||
|
||||
return view('user.dashboard', array_merge([
|
||||
'administrations' => $administrations,
|
||||
'selectedMonth' => $selectedMonth,
|
||||
'user' => $user,
|
||||
'receivedRankers' => $receivedRanking['rankers'],
|
||||
'receivedCurrentUserRankItem' => $receivedRanking['currentUserRankItem'],
|
||||
'sentRankers' => $sentRanking['rankers'],
|
||||
'sentCurrentUserRankItem' => $sentRanking['currentUserRankItem']
|
||||
], $stats));
|
||||
}
|
||||
|
||||
public function sendThankcards()
|
||||
{
|
||||
$users = $this->userService->getOtherActiveUsers(Auth::user());
|
||||
return view('user.send', compact('users'));
|
||||
}
|
||||
|
||||
public function storeThankcards(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'receiver' => [
|
||||
'required',
|
||||
\Illuminate\Validation\Rule::exists('user', 'msnv')->where('status', \App\Models\User::STATUS_ACTIVE)
|
||||
],
|
||||
'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->userService->sendThankcards(
|
||||
Auth::user(),
|
||||
$request->receiver,
|
||||
(int) $request->amount
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('messages.thank_card_send_success')]);
|
||||
}
|
||||
|
||||
public function changePasswordForm()
|
||||
{
|
||||
return view('user.change_password');
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required|min:6|confirmed',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$this->userService->updatePassword($user, $request->password);
|
||||
|
||||
$route = $user->role == \App\Models\User::ROLE_ADMIN ? 'admin.dashboard' : 'user.dashboard';
|
||||
return redirect()->route($route)->with('success', __('messages.password_change_success'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware\Admin;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\User;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check() && Auth::user()->role == User::ROLE_ADMIN && Auth::user()->status == User::STATUS_ACTIVE) {
|
||||
return $next($request);
|
||||
}
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\User;
|
||||
|
||||
class ForceChangePasswordMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check() && Auth::user()->first_login == User::FIRST_LOGIN_TRUE) {
|
||||
if (!$request->routeIs('user.change_password') && !$request->routeIs('user.update_password') && !$request->routeIs('logout')) {
|
||||
return redirect()->route('user.change_password');
|
||||
}
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware\User;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\User;
|
||||
|
||||
class MemberMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check() && in_array(Auth::user()->role, [User::ROLE_MEMBER, User::ROLE_ADMIN]) && Auth::user()->status == User::STATUS_ACTIVE) {
|
||||
return $next($request);
|
||||
}
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AddCard extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'add_card';
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'buyer',
|
||||
'num_card',
|
||||
'seller',
|
||||
'date',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Administration extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
const MAX_SEND_CARD_PER_MONTH = 5;
|
||||
|
||||
|
||||
protected $table = 'administration';
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'msnv',
|
||||
'received',
|
||||
'sender',
|
||||
'sent',
|
||||
'receiver',
|
||||
'date',
|
||||
];
|
||||
|
||||
public function senderUser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'sender', 'msnv');
|
||||
}
|
||||
|
||||
public function receiverUser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'receiver', 'msnv');
|
||||
}
|
||||
}
|
||||
@@ -2,48 +2,81 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
const ROLE_MEMBER = 0;
|
||||
const ROLE_ADMIN = 1;
|
||||
|
||||
const STATUS_INACTIVE = 0;
|
||||
const STATUS_ACTIVE = 1;
|
||||
|
||||
const FLAG_SEND_DISABLED = 0;
|
||||
const FLAG_SEND_ENABLED = 1;
|
||||
|
||||
const FIRST_LOGIN_FALSE = 0;
|
||||
const FIRST_LOGIN_TRUE = 1;
|
||||
|
||||
|
||||
protected $table = 'user';
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'msnv',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'mail',
|
||||
'pass',
|
||||
'departments',
|
||||
'avatar',
|
||||
'role',
|
||||
'status',
|
||||
'card',
|
||||
'flag_send',
|
||||
'first_login',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'pass',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
* Get the password for the user.
|
||||
*
|
||||
* @return array<string, string>
|
||||
* @return string
|
||||
*/
|
||||
protected function casts(): array
|
||||
public function getAuthPassword()
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
return $this->pass;
|
||||
}
|
||||
|
||||
// Disable remember token support in database
|
||||
public function getRememberToken()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setRememberToken($value)
|
||||
{
|
||||
}
|
||||
|
||||
public function getRememberTokenName()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function transactions()
|
||||
{
|
||||
return $this->hasMany(Administration::class, 'msnv', 'msnv');
|
||||
}
|
||||
|
||||
public function cardPurchases()
|
||||
{
|
||||
return $this->hasMany(AddCard::class, 'buyer', 'msnv');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,42 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Admin\AdminService;
|
||||
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||
use App\Services\Auth\AuthService;
|
||||
use App\Services\Auth\Contracts\AuthServiceInterface;
|
||||
use App\Services\User\Contracts\UserServiceInterface;
|
||||
use App\Services\User\UserService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->bind(AuthServiceInterface::class, AuthService::class);
|
||||
$this->app->bind(AdminServiceInterface::class, AdminService::class);
|
||||
$this->app->bind(UserServiceInterface::class, UserService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
if (str_starts_with(config('app.url'), 'https://') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')) {
|
||||
\Illuminate\Support\Facades\URL::forceScheme('https');
|
||||
}
|
||||
|
||||
if (config('app.debug')) {
|
||||
DB::listen(function ($query) {
|
||||
Log::channel('query')->info(
|
||||
sprintf(
|
||||
"[%s ms] %s | Bindings: %s",
|
||||
$query->time,
|
||||
$query->sql,
|
||||
json_encode($query->bindings)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\AddCard;
|
||||
use App\Models\Administration;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class AdminService implements AdminServiceInterface
|
||||
{
|
||||
public function getUserListWithStats(string $selectedMonth, ?string $search = null, ?string $flagSend = null): array
|
||||
{
|
||||
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||
|
||||
$usersQuery = User::select('user.*')
|
||||
->where('status', User::STATUS_ACTIVE)
|
||||
->addSelect([
|
||||
'total_received' => Administration::selectRaw('COALESCE(SUM(received), 0)')
|
||||
->whereColumn('msnv', 'user.msnv')
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth]),
|
||||
'total_sent' => Administration::selectRaw('COALESCE(SUM(sent), 0)')
|
||||
->whereColumn('msnv', 'user.msnv')
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
]);
|
||||
|
||||
$topReceivedUser = (clone $usersQuery)->orderByDesc('total_received')->first();
|
||||
$topSentUser = (clone $usersQuery)->orderByDesc('total_sent')->first();
|
||||
|
||||
if (!is_null($search) && trim($search) !== '') {
|
||||
$search = trim($search);
|
||||
$usersQuery->where(function ($q) use ($search) {
|
||||
$q->whereRaw('LOWER(msnv) LIKE ?', ['%' . strtolower($search) . '%'])
|
||||
->orWhereRaw('LOWER(mail) LIKE ?', ['%' . strtolower($search) . '%']);
|
||||
});
|
||||
}
|
||||
|
||||
if (!is_null($flagSend) && $flagSend !== '') {
|
||||
$usersQuery->where('flag_send', intval($flagSend));
|
||||
}
|
||||
|
||||
$users = $usersQuery->paginate(10)->withQueryString();
|
||||
|
||||
return compact('users', 'topReceivedUser', 'topSentUser');
|
||||
}
|
||||
|
||||
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator
|
||||
{
|
||||
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||
|
||||
return Administration::where('msnv', $msnv)
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
->orderBy('date', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function createUser(array $data): void
|
||||
{
|
||||
User::create([
|
||||
'msnv' => $data['msnv'],
|
||||
'name' => $data['name'],
|
||||
'mail' => $data['mail'],
|
||||
'pass' => md5($data['password']),
|
||||
'departments' => $data['departments'],
|
||||
'role' => $data['role'],
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 0,
|
||||
'flag_send' => User::FLAG_SEND_DISABLED,
|
||||
'first_login' => User::FIRST_LOGIN_TRUE,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUser(string $msnv, array $data): void
|
||||
{
|
||||
$user = User::where('msnv', $msnv)->firstOrFail();
|
||||
|
||||
DB::transaction(function () use ($data, $user) {
|
||||
if (!empty($data['num_card'])) {
|
||||
AddCard::create([
|
||||
'buyer' => $user->msnv,
|
||||
'num_card' => $data['num_card'],
|
||||
'seller' => Auth::user()->msnv,
|
||||
'date' => Carbon::today(),
|
||||
]);
|
||||
$user->card += $data['num_card'];
|
||||
}
|
||||
|
||||
$user->flag_send = isset($data['flag_send']) ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED;
|
||||
$user->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function deactivateUser(string $msnv): void
|
||||
{
|
||||
$user = User::where('msnv', $msnv)->firstOrFail();
|
||||
$user->status = User::STATUS_INACTIVE;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
public function resetAllCards(): void
|
||||
{
|
||||
User::where('status', User::STATUS_ACTIVE)->update(['card' => 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Contracts;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface AdminServiceInterface
|
||||
{
|
||||
public function getUserListWithStats(string $selectedMonth, ?string $search = null, ?string $flagSend = null): array;
|
||||
|
||||
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator;
|
||||
|
||||
public function createUser(array $data): void;
|
||||
|
||||
public function updateUser(string $msnv, array $data): void;
|
||||
|
||||
public function deactivateUser(string $msnv): void;
|
||||
|
||||
public function resetAllCards(): void;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\Contracts\AuthServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthService implements AuthServiceInterface
|
||||
{
|
||||
public function attemptLogin(array $credentials, Request $request): array
|
||||
{
|
||||
// Manual MD5 password check
|
||||
$user = User::where('mail', $credentials['mail'])->first();
|
||||
if (!$user || $user->pass !== md5($credentials['password'])) {
|
||||
return ['success' => false, 'error_key' => 'mail', 'error_msg' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại'];
|
||||
}
|
||||
|
||||
// Log the user in manually
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
if ($user->status != User::STATUS_ACTIVE) {
|
||||
Auth::logout();
|
||||
return ['success' => false, 'error_key' => 'permission', 'error_msg' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'user' => $user];
|
||||
}
|
||||
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
|
||||
public function getRedirectRouteForUser(User $user): string
|
||||
{
|
||||
if ($user->first_login == User::FIRST_LOGIN_TRUE) {
|
||||
return route('user.change_password');
|
||||
}
|
||||
|
||||
if ($user->role == User::ROLE_ADMIN) {
|
||||
return route('admin.dashboard');
|
||||
}
|
||||
|
||||
return route('user.dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth\Contracts;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
interface AuthServiceInterface
|
||||
{
|
||||
public function attemptLogin(array $credentials, Request $request): array;
|
||||
|
||||
public function logout(Request $request): void;
|
||||
|
||||
public function getRedirectRouteForUser(User $user): string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\User\Contracts;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface UserServiceInterface
|
||||
{
|
||||
public function getDashboard(User $user, string $selectedMonth): LengthAwarePaginator;
|
||||
|
||||
public function getOtherActiveUsers(User $currentUser): \Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
public function sendThankcards(User $sender, string $receiverMsnv, int $amount): void;
|
||||
|
||||
public function updatePassword(User $user, string $newPassword): void;
|
||||
|
||||
public function getPersonalStats(User $user, string $selectedMonth): array;
|
||||
|
||||
public function getRankingList(string $type, string $selectedMonth, User $currentUser): array;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\User;
|
||||
|
||||
use App\Models\Administration;
|
||||
use App\Models\User;
|
||||
use App\Services\User\Contracts\UserServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserService implements UserServiceInterface
|
||||
{
|
||||
public function getDashboard(User $user, string $selectedMonth): LengthAwarePaginator
|
||||
{
|
||||
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||
|
||||
return Administration::where('msnv', $user->msnv)
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
->orderBy('date', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function getOtherActiveUsers(User $currentUser): Collection
|
||||
{
|
||||
return User::where('status', User::STATUS_ACTIVE)
|
||||
->where('msnv', '!=', $currentUser->msnv)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function sendThankcards(User $sender, string $receiverMsnv, int $amount): void
|
||||
{
|
||||
if ($sender->flag_send == User::FLAG_SEND_DISABLED) {
|
||||
throw new \RuntimeException(__('messages.error.no_send_permission'));
|
||||
}
|
||||
|
||||
$receiver = User::where('msnv', $receiverMsnv)->first();
|
||||
if (!$receiver || $receiver->status != User::STATUS_ACTIVE) {
|
||||
throw new \RuntimeException(__('messages.error.receiver_invalid'));
|
||||
}
|
||||
|
||||
$startOfMonth = Carbon::now()->startOfMonth();
|
||||
$endOfMonth = Carbon::now()->endOfMonth();
|
||||
|
||||
$cardsSentToThisUserThisMonth = Administration::where('msnv', $sender->msnv)
|
||||
->where('receiver', $receiverMsnv)
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
->sum('sent');
|
||||
|
||||
if ($cardsSentToThisUserThisMonth + $amount > Administration::MAX_SEND_CARD_PER_MONTH) {
|
||||
throw new \RuntimeException(
|
||||
__('messages.error.max_send_limit_template', [
|
||||
'max' => Administration::MAX_SEND_CARD_PER_MONTH,
|
||||
'sent' => $cardsSentToThisUserThisMonth
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if ($sender->card < $amount) {
|
||||
throw new \RuntimeException(__('messages.error.not_enough_cards'));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($sender, $receiverMsnv, $amount) {
|
||||
Administration::create([
|
||||
'msnv' => $receiverMsnv,
|
||||
'received' => $amount,
|
||||
'sender' => $sender->msnv,
|
||||
'sent' => 0,
|
||||
'receiver' => null,
|
||||
'date' => Carbon::today(),
|
||||
]);
|
||||
|
||||
Administration::create([
|
||||
'msnv' => $sender->msnv,
|
||||
'received' => 0,
|
||||
'sender' => null,
|
||||
'sent' => $amount,
|
||||
'receiver' => $receiverMsnv,
|
||||
'date' => Carbon::today(),
|
||||
]);
|
||||
|
||||
$sender->card -= $amount;
|
||||
$sender->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function updatePassword(User $user, string $newPassword): void
|
||||
{
|
||||
$user->pass = md5($newPassword);
|
||||
$user->first_login = User::FIRST_LOGIN_FALSE;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
public function getPersonalStats(User $user, string $selectedMonth): array
|
||||
{
|
||||
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||
|
||||
$receivedCount = (int) Administration::where('msnv', $user->msnv)
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
->sum('received');
|
||||
|
||||
$sentCount = (int) Administration::where('msnv', $user->msnv)
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
->sum('sent');
|
||||
|
||||
// Calculate current rank (based on received)
|
||||
$rankings = User::where('status', User::STATUS_ACTIVE)
|
||||
->addSelect([
|
||||
'score' => Administration::selectRaw('COALESCE(SUM(received), 0)')
|
||||
->whereColumn('msnv', 'user.msnv')
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
])
|
||||
->get()
|
||||
->filter(fn($u) => $u->score > 0)
|
||||
->sortBy([
|
||||
['score', 'desc'],
|
||||
['msnv', 'asc']
|
||||
])
|
||||
->values();
|
||||
|
||||
$currentRank = 0;
|
||||
$prevScore = null;
|
||||
$rank = 1;
|
||||
$denseRank = 0;
|
||||
|
||||
foreach ($rankings as $item) {
|
||||
$score = (int) $item->score;
|
||||
if ($score !== $prevScore) {
|
||||
$denseRank = $rank;
|
||||
$prevScore = $score;
|
||||
}
|
||||
|
||||
if ($item->msnv == $user->msnv) {
|
||||
$currentRank = $denseRank;
|
||||
break;
|
||||
}
|
||||
$rank++;
|
||||
}
|
||||
|
||||
return [
|
||||
'receivedCount' => $receivedCount,
|
||||
'sentCount' => $sentCount,
|
||||
'currentRank' => $currentRank
|
||||
];
|
||||
}
|
||||
|
||||
public function getRankingList(string $type, string $selectedMonth, User $currentUser): array
|
||||
{
|
||||
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||
|
||||
$column = $type === 'sent' ? 'sent' : 'received';
|
||||
|
||||
$allActiveUsers = User::where('status', User::STATUS_ACTIVE)
|
||||
->addSelect([
|
||||
'score' => Administration::selectRaw('COALESCE(SUM(' . $column . '), 0)')
|
||||
->whereColumn('msnv', 'user.msnv')
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
])
|
||||
->get();
|
||||
|
||||
$rankedUsers = $allActiveUsers->filter(fn($u) => $u->score > 0)
|
||||
->sortBy([
|
||||
['score', 'desc'],
|
||||
['msnv', 'asc']
|
||||
])
|
||||
->values();
|
||||
|
||||
$prevScore = null;
|
||||
$rank = 1;
|
||||
$denseRank = 0;
|
||||
|
||||
$rankers = [];
|
||||
$currentUserRankItem = null;
|
||||
|
||||
foreach ($rankedUsers as $index => $userItem) {
|
||||
$score = (int) $userItem->score;
|
||||
if ($score !== $prevScore) {
|
||||
$denseRank = $rank;
|
||||
$prevScore = $score;
|
||||
}
|
||||
|
||||
$userItem->rank = $denseRank;
|
||||
$userItem->score = $score;
|
||||
|
||||
if ($userItem->msnv == $currentUser->msnv) {
|
||||
$currentUserRankItem = $userItem;
|
||||
}
|
||||
|
||||
if ($index < 4) {
|
||||
$rankers[] = $userItem;
|
||||
}
|
||||
$rank++;
|
||||
}
|
||||
|
||||
if (!$currentUserRankItem) {
|
||||
$currentUserModel = $allActiveUsers->first(fn($u) => $u->msnv == $currentUser->msnv);
|
||||
if ($currentUserModel) {
|
||||
$currentUserRankItem = clone $currentUserModel;
|
||||
$currentUserRankItem->score = 0;
|
||||
$currentUserRankItem->rank = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rankers' => $rankers,
|
||||
'currentUserRankItem' => $currentUserRankItem
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,32 @@
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function () {
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/admin.php'));
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\Admin\AdminMiddleware::class,
|
||||
'member' => \App\Http\Middleware\User\MemberMiddleware::class,
|
||||
'force_change_password' => \App\Http\Middleware\ForceChangePasswordMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, \Illuminate\Http\Request $request) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $e->getMessage()], 401);
|
||||
}
|
||||
return redirect()->guest(route('login'))->with('session_expired', true);
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
(async () => {
|
||||
const dir = './docs/notification-review';
|
||||
if (!fs.existsSync(dir)){
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('Launching browser...');
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
console.log('Navigating to showcase...');
|
||||
await page.goto('http://localhost:8000/notification-showcase', { waitUntil: 'networkidle2' });
|
||||
|
||||
const cases = [
|
||||
{ id: 1, name: 'login_failed' },
|
||||
{ id: 2, name: 'access_denied' },
|
||||
{ id: 3, name: 'first_login' },
|
||||
{ id: 4, name: 'delete_user_confirmation' },
|
||||
{ id: 5, name: 'create_user_success' },
|
||||
{ id: 6, name: 'email_already_exists' },
|
||||
{ id: 7, name: 'employee_id_already_exists' },
|
||||
{ id: 8, name: 'no_data_found' },
|
||||
{ id: 9, name: 'not_enough_thankcards' },
|
||||
{ id: 10, name: 'max_5_thankcards_per_month' },
|
||||
{ id: 11, name: 'send_thankcards_confirmation' },
|
||||
{ id: 12, name: 'send_thankcards_success' },
|
||||
{ id: 13, name: 'password_confirmation_mismatch' },
|
||||
{ id: 14, name: 'change_password_success' },
|
||||
{ id: 15, name: 'generic_system_error' }
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
console.log(`Triggering Case ${c.id}: ${c.name}...`);
|
||||
// Click the button
|
||||
await page.click(`#case-btn-${c.id}`);
|
||||
|
||||
// Wait for animation
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// Save screenshot
|
||||
const filename = `case_${String(c.id).padStart(2, '0')}_${c.name}.png`;
|
||||
const filepath = path.join(dir, filename);
|
||||
await page.screenshot({ path: filepath });
|
||||
console.log(`Saved screenshot to ${filepath}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Finished capturing all 15 screenshots!');
|
||||
})();
|
||||
@@ -78,7 +78,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locale' => env('APP_LOCALE', 'vi'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ return [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
'permission' => 0666,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
@@ -71,6 +72,7 @@ return [
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
'permission' => 0666,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
@@ -123,6 +125,13 @@ return [
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'query' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/query.log'),
|
||||
'level' => 'debug',
|
||||
'permission' => 0666,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
@@ -11,30 +11,22 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
Schema::create('user', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('msnv')->unique();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->string('mail');
|
||||
$table->string('pass');
|
||||
$table->string('departments');
|
||||
$table->string('avatar')->nullable();
|
||||
$table->tinyInteger('role')->default(0)->comment('0: member, default / 1: admin');
|
||||
$table->tinyInteger('status')->default(1)->comment('0: đã nghỉ / 1: đang làm, default');
|
||||
$table->integer('card')->default(0);
|
||||
$table->tinyInteger('flag_send')->default(0)->comment('0: không được gửi card / 1: được gửi card');
|
||||
$table->tinyInteger('first_login')->default(1)->comment('0: không là lần đầu / 1: lần đầu, default');
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
// Only user table remains
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +34,6 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
Schema::dropIfExists('user');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('add_card', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('buyer');
|
||||
$table->integer('num_card');
|
||||
$table->integer('seller');
|
||||
$table->date('date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('add_card');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('administration', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('msnv');
|
||||
$table->integer('received')->nullable();
|
||||
$table->integer('sender')->nullable();
|
||||
$table->integer('sent')->nullable();
|
||||
$table->integer('receiver')->nullable();
|
||||
$table->date('date')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('administration');
|
||||
}
|
||||
};
|
||||
@@ -15,11 +15,8 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
$this->call([
|
||||
MockDataSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\User;
|
||||
use App\Models\Administration;
|
||||
use App\Models\AddCard;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MockDataSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Clear old data to avoid duplicates when running again
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
|
||||
User::truncate();
|
||||
Administration::truncate();
|
||||
AddCard::truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
|
||||
|
||||
// The default password is now set to 1234@Dcba according to the design requirements
|
||||
$password = md5('1234@Dcba');
|
||||
|
||||
// 1. CREATE USERS
|
||||
// Admins
|
||||
User::create([
|
||||
'msnv' => 9999,
|
||||
'name' => 'System Admin',
|
||||
'mail' => 'admin@runsystem.net',
|
||||
'pass' => $password,
|
||||
'departments' => 1,
|
||||
'role' => User::ROLE_ADMIN,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 0,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'msnv' => 9998,
|
||||
'name' => 'Second Admin',
|
||||
'mail' => 'admin2@runsystem.net',
|
||||
'pass' => $password,
|
||||
'departments' => 1,
|
||||
'role' => User::ROLE_ADMIN,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 0,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
// Active Users (Initialize with specific users needed for logs/transactions)
|
||||
$users = [
|
||||
['msnv' => 1001, 'name' => 'Nguyễn Văn A', 'mail' => 'nguyenvana@runsystem.net', 'card' => 10, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
|
||||
['msnv' => 1002, 'name' => 'Trần Thị Ngọc', 'mail' => 'tranthingoc@runsystem.net', 'card' => 3, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
|
||||
['msnv' => 1003, 'name' => 'Lê Kim Thư', 'mail' => 'lekiemthu@runsystem.net', 'card' => 6, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_TRUE], // First-time login
|
||||
['msnv' => 1004, 'name' => 'Phòng Nhân Sự', 'mail' => 'phongnhansu@runsystem.net', 'card' => 0, 'flag' => User::FLAG_SEND_DISABLED, 'first_login' => User::FIRST_LOGIN_FALSE], // Disabled sending permission
|
||||
['msnv' => 1005, 'name' => 'Bản Giang', 'mail' => 'bangiang@runsystem.net', 'card' => 36, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
|
||||
];
|
||||
|
||||
// Generate additional users to reach exactly 100 member users
|
||||
$firstNames = ['Nguyễn', 'Trần', 'Lê', 'Phạm', 'Hoàng', 'Huỳnh', 'Phan', 'Vũ', 'Võ', 'Đặng'];
|
||||
$middleNames = ['Văn', 'Thị', 'Minh', 'Đức', 'Quốc', 'Thanh', 'Hữu', 'Ngọc', 'Quỳnh', 'Thu'];
|
||||
$lastNames = ['Anh', 'Bình', 'Chi', 'Dương', 'Em', 'Giang', 'Huy', 'Khánh', 'Lâm', 'Nam', 'Oanh', 'Phúc', 'Quang', 'Sơn', 'Trang', 'Vy'];
|
||||
|
||||
for ($i = 6; $i <= 100; $i++) {
|
||||
$randomName = $firstNames[array_rand($firstNames)] . ' ' . $middleNames[array_rand($middleNames)] . ' ' . $lastNames[array_rand($lastNames)];
|
||||
$users[] = [
|
||||
'msnv' => 1000 + $i,
|
||||
'name' => $randomName,
|
||||
'mail' => 'dev' . str_pad($i, 3, '0', STR_PAD_LEFT) . '@runsystem.net',
|
||||
'card' => rand(0, 30),
|
||||
'flag' => rand(0, 5) > 0 ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED,
|
||||
'first_login' => rand(0, 4) == 0 ? User::FIRST_LOGIN_TRUE : User::FIRST_LOGIN_FALSE
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($users as $u) {
|
||||
User::create([
|
||||
'msnv' => $u['msnv'],
|
||||
'name' => $u['name'],
|
||||
'mail' => $u['mail'],
|
||||
'pass' => $password,
|
||||
'departments' => rand(1, 5),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => $u['card'],
|
||||
'flag_send' => $u['flag'],
|
||||
'first_login' => $u['first_login'],
|
||||
]);
|
||||
|
||||
// For other users, if they have cards, create a matching purchase log in June 2026
|
||||
if (!in_array($u['msnv'], [1001, 1002, 1003, 1005])) {
|
||||
if ($u['card'] > 0) {
|
||||
AddCard::create([
|
||||
'buyer' => $u['msnv'],
|
||||
'num_card' => $u['card'],
|
||||
'seller' => 9999,
|
||||
'date' => '2026-06-01'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inactive User
|
||||
User::create([
|
||||
'msnv' => 9997,
|
||||
'name' => 'Nghi Duy',
|
||||
'mail' => 'nghiduy@runsystem.net',
|
||||
'pass' => $password,
|
||||
'departments' => 2,
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_INACTIVE,
|
||||
'card' => 0,
|
||||
'flag_send' => User::FLAG_SEND_DISABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
// 2. CREATE CARD ADDITION LOGS (add_card table)
|
||||
// Manual purchase history for specific users:
|
||||
$addCardLogs = [
|
||||
// 1001: total 30 cards bought
|
||||
['buyer' => 1001, 'num_card' => 15, 'date' => '2026-06-01'],
|
||||
['buyer' => 1001, 'num_card' => 10, 'date' => '2026-07-01'],
|
||||
['buyer' => 1001, 'num_card' => 5, 'date' => '2026-08-01'],
|
||||
|
||||
// 1002: total 15 cards bought
|
||||
['buyer' => 1002, 'num_card' => 10, 'date' => '2026-06-02'],
|
||||
['buyer' => 1002, 'num_card' => 5, 'date' => '2026-07-01'],
|
||||
|
||||
// 1003: total 15 cards bought
|
||||
['buyer' => 1003, 'num_card' => 10, 'date' => '2026-06-03'],
|
||||
['buyer' => 1003, 'num_card' => 5, 'date' => '2026-08-02'],
|
||||
|
||||
// 1005: total 50 cards bought
|
||||
['buyer' => 1005, 'num_card' => 50, 'date' => '2026-06-04'],
|
||||
];
|
||||
|
||||
foreach ($addCardLogs as $log) {
|
||||
AddCard::create([
|
||||
'buyer' => $log['buyer'],
|
||||
'num_card' => $log['num_card'],
|
||||
'seller' => 9999,
|
||||
'date' => $log['date']
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. CREATE CARD SENDING HISTORY (administration table)
|
||||
$transactions = [
|
||||
// June 2026 transactions
|
||||
['sender' => 1001, 'receiver' => 1005, 'amount' => 5, 'date' => '2026-06-05'],
|
||||
['sender' => 1001, 'receiver' => 1002, 'amount' => 3, 'date' => '2026-06-12'],
|
||||
['sender' => 1002, 'receiver' => 1004, 'amount' => 4, 'date' => '2026-06-10'],
|
||||
['sender' => 1003, 'receiver' => 1001, 'amount' => 2, 'date' => '2026-06-15'],
|
||||
['sender' => 1005, 'receiver' => 1002, 'amount' => 5, 'date' => '2026-06-20'],
|
||||
|
||||
// July 2026 transactions
|
||||
['sender' => 1001, 'receiver' => 1002, 'amount' => 5, 'date' => '2026-07-02'],
|
||||
['sender' => 1001, 'receiver' => 1003, 'amount' => 3, 'date' => '2026-07-10'],
|
||||
['sender' => 1002, 'receiver' => 1001, 'amount' => 5, 'date' => '2026-07-15'],
|
||||
['sender' => 1003, 'receiver' => 1004, 'amount' => 4, 'date' => '2026-07-08'],
|
||||
['sender' => 1005, 'receiver' => 1002, 'amount' => 4, 'date' => '2026-07-12'],
|
||||
|
||||
// August 2026 transactions
|
||||
['sender' => 1001, 'receiver' => 1005, 'amount' => 4, 'date' => '2026-08-05'],
|
||||
['sender' => 1002, 'receiver' => 1003, 'amount' => 3, 'date' => '2026-08-12'],
|
||||
['sender' => 1003, 'receiver' => 1001, 'amount' => 3, 'date' => '2026-08-10'],
|
||||
['sender' => 1005, 'receiver' => 1004, 'amount' => 5, 'date' => '2026-08-15'],
|
||||
];
|
||||
|
||||
foreach ($transactions as $t) {
|
||||
// Sender record
|
||||
Administration::create([
|
||||
'msnv' => $t['sender'],
|
||||
'received' => 0,
|
||||
'sender' => null,
|
||||
'sent' => $t['amount'],
|
||||
'receiver' => $t['receiver'],
|
||||
'date' => $t['date']
|
||||
]);
|
||||
|
||||
// Receiver record
|
||||
Administration::create([
|
||||
'msnv' => $t['receiver'],
|
||||
'received' => $t['amount'],
|
||||
'sender' => $t['sender'],
|
||||
'sent' => 0,
|
||||
'receiver' => null,
|
||||
'date' => $t['date']
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM php:8.3-fpm
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git unzip zip curl \
|
||||
libpng-dev libjpeg-dev libfreetype6-dev \
|
||||
libonig-dev libxml2-dev libzip-dev \
|
||||
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
COPY docker/php/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["php-fpm"]
|
||||
@@ -0,0 +1,160 @@
|
||||
# Laravel Docker Setup - thankcard-system
|
||||
|
||||
## 1. Cấu trúc thư mục
|
||||
|
||||
```txt
|
||||
thankcard-system/
|
||||
├─ .env
|
||||
├─ artisan
|
||||
├─ composer.json
|
||||
├─ public/
|
||||
├─ storage/
|
||||
├─ bootstrap/
|
||||
├─ doc/
|
||||
└─ docker/
|
||||
├─ docker-compose.yml
|
||||
├─ Dockerfile
|
||||
├─ README.md
|
||||
├─ env/
|
||||
│ └─ .env.app
|
||||
├─ nginx/
|
||||
│ └─ default.conf
|
||||
└─ php/
|
||||
└─ entrypoint.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Khởi chạy dự án (Docker)
|
||||
|
||||
Đứng tại thư mục root của dự án `thankcard-system` và chạy:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Hoặc di chuyển vào thư mục `docker/` rồi chạy:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Sau khi các container chạy thành công, truy cập hệ thống tại trình duyệt:
|
||||
|
||||
```txt
|
||||
http://localhost:8888
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Cơ chế cấu hình môi trường (.env)
|
||||
|
||||
- File `.env` chính của Laravel vẫn nằm ở thư mục root để phục vụ máy local.
|
||||
- Khi chạy trong container Docker, hệ thống sẽ sử dụng file **`docker/env/.env.app`** để ghi đè các cấu hình cần thiết (ví dụ: `DB_HOST=host.docker.internal` giúp PHP container kết nối ra cơ sở dữ liệu trên máy thật).
|
||||
|
||||
---
|
||||
|
||||
## 4. Các lệnh cần chạy thủ công khi cập nhật code
|
||||
|
||||
### 4.1. Khi thêm/cập nhật thư viện mới (`composer.json`)
|
||||
|
||||
Khi cập nhật hoặc cài đặt gói package PHP mới, bạn cần chạy lệnh cài đặt **bên trong container**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app composer install
|
||||
```
|
||||
|
||||
### 4.2. Khi cập nhật giao diện (CSS / JS / Tailwind classes mới)
|
||||
|
||||
Cập nhật giao diện cần biên dịch lại tài nguyên frontend. Bạn có 2 cách:
|
||||
|
||||
**1️⃣ Chạy trên máy host (đề xuất)**
|
||||
```bash
|
||||
npm install # một lần, nếu chưa có các package
|
||||
npm run dev # Vite dev server, hot‑reload
|
||||
```
|
||||
|
||||
**2️⃣ Chạy trong Docker (sử dụng container `node`)**
|
||||
```bash
|
||||
# Cài đặt các package (chỉ chạy 1 lần)
|
||||
docker compose -f docker/docker-compose.yml exec node npm install
|
||||
|
||||
# Chế độ phát triển (hot‑reload)
|
||||
docker compose -f docker/docker-compose.yml exec node npm run dev
|
||||
|
||||
# Hoặc biên dịch bản production
|
||||
docker compose -f docker/docker-compose.yml exec node npm run build
|
||||
```
|
||||
|
||||
Container `node` đã expose port **5173**, cho phép truy cập Vite dev server tại `http://localhost:5173`. Khi `npm run dev` đang chạy, mọi thay đổi CSS/JS sẽ tự động cập nhật trong container `app` nhờ volume mount.
|
||||
|
||||
### 4.3. Khi cập nhật Database (Migrations)
|
||||
|
||||
Để cập nhật cơ sở dữ liệu khi có migrations mới:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan migrate
|
||||
```
|
||||
|
||||
### 4.4. Khi chạy dữ liệu mẫu (Seeders)
|
||||
|
||||
Để tạo dữ liệu mẫu (seed) vào cơ sở dữ liệu:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan db:seed
|
||||
```
|
||||
|
||||
Nếu chỉ muốn chạy một file seeder cố định (ví dụ: `UserSeeder`):
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan db:seed --class=UserSeeder
|
||||
```
|
||||
|
||||
Hoặc để vừa chạy lệnh migrate vừa tự động seed luôn dữ liệu:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan migrate --seed
|
||||
```
|
||||
|
||||
### 4.5. Khi chỉnh sửa file `.env` hoặc `.env.app`
|
||||
|
||||
Bạn cần khởi động lại container để cập nhật biến môi trường mới và tiến hành xóa cache cấu hình:
|
||||
|
||||
```bash
|
||||
# 1. Restart container app
|
||||
docker compose -f docker/docker-compose.yml restart app
|
||||
|
||||
# 2. Xóa cache cấu hình trong container
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan config:clear
|
||||
```
|
||||
|
||||
### 4.6. Mở công cụ quản lý Database (phpMyAdmin)
|
||||
|
||||
Dự án có sẵn một file cấu hình riêng để mở giao diện quản lý MySQL (đã được trỏ sẵn vào database trên máy bạn):
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.phpmyadmin.yml up -d
|
||||
```
|
||||
Sau đó truy cập: `http://localhost:8889` để vào phpMyAdmin.
|
||||
|
||||
---
|
||||
|
||||
## 5. Các lệnh tiện ích khác
|
||||
|
||||
- **Truy cập vào Bash của container app:**
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app bash
|
||||
```
|
||||
- **Xem log Laravel thời gian thực:**
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app tail -f storage/logs/laravel.log
|
||||
```
|
||||
- **Dọn dẹp toàn bộ cache của Laravel:**
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan optimize:clear
|
||||
```
|
||||
- **Dừng các container:**
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
```
|
||||
|
||||
docker compose -f docker/docker-compose.yml exec app php artisan key:generate
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:latest
|
||||
container_name: thankcard-system-phpmyadmin
|
||||
ports:
|
||||
- "8889:80"
|
||||
environment:
|
||||
PMA_HOST: host.docker.internal
|
||||
PMA_PORT: 3306
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: thankcard-system-app
|
||||
container_name: thankcard-system-app
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ..:/var/www/html
|
||||
|
||||
networks:
|
||||
- thankcard-system-network
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: thankcard-system-nginx
|
||||
ports:
|
||||
- "8888:80"
|
||||
volumes:
|
||||
- ..:/var/www/html
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- thankcard-system-network
|
||||
|
||||
node:
|
||||
image: node:20-alpine
|
||||
container_name: thankcard-system-node
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ..:/var/www/html
|
||||
command: sh -c "npm install && npm run build"
|
||||
ports:
|
||||
- "5173:5173"
|
||||
networks:
|
||||
- thankcard-system-network
|
||||
|
||||
networks:
|
||||
thankcard-system-network:
|
||||
name: thankcard-system-network
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd /var/www/html
|
||||
|
||||
# Luôn chạy composer install để đảm bảo vendor được cập nhật
|
||||
composer install --no-interaction --prefer-dist
|
||||
|
||||
mkdir -p storage/logs \
|
||||
storage/framework/cache \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
bootstrap/cache
|
||||
|
||||
touch storage/logs/laravel.log storage/logs/query.log
|
||||
chmod 666 storage/logs/laravel.log storage/logs/query.log
|
||||
chmod -R 777 storage bootstrap/cache
|
||||
|
||||
# Tạo symlink cho storage nếu chưa có (để public file ảnh, upload,...)
|
||||
if [ ! -L "public/storage" ]; then
|
||||
php artisan storage:link
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Notification & Popup Review List
|
||||
|
||||
This document lists all the standard dialogs, popups, toasts, and empty states implemented for the Thankcard System. These screenshots are captured automatically for Business Analyst (BA) and Quality Assurance (QA) review and approval.
|
||||
|
||||
## Common Components Implemented
|
||||
|
||||
The following reusable/common components are created and used across all display cases:
|
||||
1. **Confirm Dialog**: Dialog prompting for confirmation before an action is executed (danger and primary types).
|
||||
2. **Error Dialog**: Informing users of errors or validation failures.
|
||||
3. **Success Dialog**: Standard dialog to show successful actions.
|
||||
4. **Warning Dialog**: Warnings related to constraints or user balance.
|
||||
5. **Info Dialog**: Friendly dialog for guidance (e.g., first login, change password required).
|
||||
6. **Toast**: Slide-in non-blocking notification for quick feedback.
|
||||
7. **Empty State**: Centered inline placeholder for lists containing no data.
|
||||
|
||||
---
|
||||
|
||||
## Review Case Matrix
|
||||
|
||||
| Case ID | Feature | Display Case | Notification Type | Message | Screenshot Path |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **01** | Authentication | Login failed | Inline (Error) | Thông tin đăng nhập không đúng. Vui lòng nhập lại | [case_01_login_failed.png](case_01_login_failed.png) |
|
||||
| **02** | Authorization | Access denied / disabled | Error Dialog | Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên | [case_02_access_denied.png](case_02_access_denied.png) |
|
||||
| **03** | Authentication | First login | Info Dialog | Vui lòng đổi mật khẩu trước khi tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) |
|
||||
| **04** | User Management | Delete user confirmation | Confirm Dialog (Danger) | Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa. | [case_04_delete_user_confirmation.png](case_04_delete_user_confirmation.png) |
|
||||
| **05** | User Management | Create user success | Success Dialog | Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng. | [case_05_create_user_success.png](case_05_create_user_success.png) |
|
||||
| **06** | User Management | Email already exists | Error Dialog | Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác. | [case_06_email_already_exists.png](case_06_email_already_exists.png) |
|
||||
| **07** | User Management | Employee ID already exists | Error Dialog | Mã nhân viên (MSNV) 'NV0001' đã được gán cho một tài khoản khác trong hệ thống. Vui lòng kiểm tra lại. | [case_07_employee_id_already_exists.png](case_07_employee_id_already_exists.png) |
|
||||
| **08** | General | No data found | Empty State | Không tìm thấy người dùng hoặc giao dịch nào phù hợp với bộ lọc tìm kiếm hiện tại của bạn. Vui lòng thử lại với từ khóa khác. | [case_08_no_data_found.png](case_08_no_data_found.png) |
|
||||
| **09** | Thankcard Sending | Not enough thankcards | Warning Dialog | Số dư Thankcard hiện tại của bạn là 0. Bạn không thể gửi thêm thẻ cho đến khi số dư được đặt lại vào đầu tháng tới. | [case_09_not_enough_thankcards.png](case_09_not_enough_thankcards.png) |
|
||||
| **10** | Thankcard Sending | Maximum 5 thankcards per user/month | Warning Dialog | Bạn đã đạt đến hạn mức gửi tối đa 5 Thankcard trong tháng này. Vui lòng đợi đến tháng sau để tiếp tục gửi lời cảm ơn. | [case_10_max_5_thankcards_per_month.png](case_10_max_5_thankcards_per_month.png) |
|
||||
| **11** | Thankcard Sending | Send thankcards confirmation | Confirm Dialog (Primary) | Bạn đang thực hiện gửi 1 Thankcard tới Trần Thị B với lời nhắn: 'Cảm ơn bạn đã hỗ trợ dự án'. Bạn có chắc chắn muốn gửi? | [case_11_send_thankcards_confirmation.png](case_11_send_thankcards_confirmation.png) |
|
||||
| **12** | Thankcard Sending | Send thankcards success | Success Dialog | Thankcard của bạn đã được gửi thành công đến Trần Thị B. Lượt gửi còn lại trong tháng của bạn: 4/5. | [case_12_send_thankcards_success.png](case_12_send_thankcards_success.png) |
|
||||
| **13** | Password Management | Password confirmation mismatch | Error Dialog | Mật khẩu xác nhận nhập lại không khớp với mật khẩu mới. Vui lòng nhập lại chính xác cả hai trường. | [case_13_password_confirmation_mismatch.png](case_13_password_confirmation_mismatch.png) |
|
||||
| **14** | Password Management | Change password success | Success Dialog | Mật khẩu của bạn đã được cập nhật thành công. Vui lòng sử dụng mật khẩu mới cho các lần đăng nhập tiếp theo. | [case_14_change_password_success.png](case_14_change_password_success.png) |
|
||||
| **15** | General | Generic system error | Error Dialog | Đã xảy ra sự cố kết nối với máy chủ (Mã lỗi: 500 - Internal Server Error). Vui lòng thử lại sau hoặc báo cáo cho đội ngũ kỹ thuật. | [case_15_generic_system_error.png](case_15_generic_system_error.png) |
|
||||
|
||||
---
|
||||
*Note: All screenshots were automatically captured under `1280x800` viewport to ensure precise rendering of transitions and CSS styling.*
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,124 @@
|
||||
# Tài liệu cấu hình và sử dụng System Error Pages
|
||||
|
||||
Tài liệu này hướng dẫn chi tiết cách hoạt động, các trường hợp sử dụng, cách kích hoạt (trigger) và kiểm thử các trang lỗi hệ thống trong dự án **thankcard-system**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Danh sách các trang lỗi & Tình huống sử dụng
|
||||
|
||||
| Status Code | Tên lỗi | Tình huống sử dụng | Key Localization (`errors.php`) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **401** | Unauthorized | Người dùng chưa đăng nhập nhưng cố truy cập tài nguyên yêu cầu xác thực. | `errors.401_title` / `errors.401_message` |
|
||||
| **403** | Forbidden | Người dùng đã đăng nhập nhưng không có quyền hạn thực hiện hành động này (ví dụ: User thường cố truy cập chức năng Admin). | `errors.403_title` / `errors.403_message` |
|
||||
| **404** | Not Found | Đường dẫn (URL) không tồn tại, hoặc khi tìm dữ liệu bằng Model binding/Query không thấy mà muốn trả về lỗi. | `errors.404_title` / `errors.404_message` |
|
||||
| **419** | Session Expired | Phiên làm việc hết hạn hoặc token CSRF không hợp lệ/hết hạn khi gửi Form (POST/PUT/DELETE). | `errors.419_title` / `errors.419_message` |
|
||||
| **429** | Too Many Requests | Người dùng/IP gửi quá nhiều yêu cầu trong thời gian ngắn (vượt giới hạn Rate Limit của Middleware). | `errors.429_title` / `errors.429_message` |
|
||||
| **500** | Internal Error | Lỗi xảy ra từ phía máy chủ hoặc do code PHP bị lỗi crash, kết nối cơ sở dữ liệu thất bại... | `errors.500_title` / `errors.500_message` |
|
||||
| **503** | Service Unavailable | Hệ thống đang được bảo trì khi chạy lệnh `php artisan down`. | `errors.503_title` / `errors.503_message` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Cách kích hoạt (Trigger) trong Laravel
|
||||
|
||||
Trong Laravel, bạn có thể trigger các trang lỗi này bằng nhiều cách khác nhau tùy thuộc vào ngữ cảnh.
|
||||
|
||||
### 2.1. Sử dụng hàm `abort()` (Khuyên dùng trong Controller/Service)
|
||||
Hàm `abort()` sẽ ném ra một `HttpException` và Laravel sẽ tự động bắt lấy để render trang lỗi tương ứng nằm trong `resources/views/errors/{status}.blade.php`.
|
||||
|
||||
```php
|
||||
// Ví dụ kiểm tra quyền trong Controller
|
||||
if ($user->role !== 'admin') {
|
||||
abort(403, 'Bạn không có quyền quản trị viên.');
|
||||
}
|
||||
|
||||
// Ví dụ không tìm thấy tài nguyên
|
||||
$card = Card::find($id);
|
||||
if (!$card) {
|
||||
abort(404, 'Không tìm thấy thẻ cảm ơn.');
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2. Sử dụng lệnh bảo trì hệ thống (Maintenance Mode)
|
||||
Kích hoạt lỗi **503** bằng cách đưa ứng dụng vào trạng thái bảo trì:
|
||||
```bash
|
||||
php artisan down
|
||||
```
|
||||
Để tắt chế độ bảo trì và đưa hệ thống hoạt động bình thường:
|
||||
```bash
|
||||
php artisan up
|
||||
```
|
||||
|
||||
### 2.3. Sử dụng Middleware (Rate Limiting cho lỗi 429)
|
||||
Laravel tích hợp sẵn middleware `throttle`. Bạn có thể áp dụng nó trong file Routes (`routes/web.php` hoặc `routes/api.php`):
|
||||
```php
|
||||
Route::middleware('throttle:60,1')->group(function () {
|
||||
Route::get('/send-card', [UserController::class, 'send']);
|
||||
});
|
||||
```
|
||||
*(Nếu một IP gọi quá 60 lần/phút, Laravel sẽ tự động ném ra lỗi 429 và render trang 429)*
|
||||
|
||||
---
|
||||
|
||||
## 3. Ví dụ triển khai thực tế
|
||||
|
||||
### Trong Controller/Service:
|
||||
```php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\User\Contracts\UserServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
protected $userService;
|
||||
|
||||
public function __construct(UserServiceInterface $userService)
|
||||
{
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$user = $this->userService->findUserById($id);
|
||||
|
||||
if (!$user) {
|
||||
// Tự động render view errors/404.blade.php
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('user.profile', compact('user'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist kiểm thử các trang lỗi
|
||||
|
||||
Để chắc chắn các trang lỗi hoạt động chính xác và hiển thị đẹp mắt, hãy tiến hành kiểm thử theo checklist sau:
|
||||
|
||||
- [ ] **Test 401 Unauthorized:**
|
||||
- Tạm thời gỡ bỏ middleware auth ở một route và ném `abort(401)`.
|
||||
- Kiểm tra xem có hiển thị nút **"Đi đến trang Đăng Nhập"** hay không.
|
||||
- [ ] **Test 403 Forbidden:**
|
||||
- Đăng nhập tài khoản User thường, sau đó truy cập một route dành riêng cho Admin hoặc ném `abort(403)`.
|
||||
- Kiểm tra xem nút **"Quay lại Trang Chủ"** có hoạt động chính xác không.
|
||||
- [ ] **Test 404 Not Found:**
|
||||
- Truy cập một đường dẫn ngẫu nhiên không khai báo trong route (ví dụ: `http://localhost:8888/path-does-not-exist`).
|
||||
- Kiểm tra giao diện và nút **"Quay lại Trang Chủ"**.
|
||||
- [ ] **Test 419 Session Expired:**
|
||||
- Mở một trang có form POST, xóa trường `_token` ẩn (CSRF) trong thẻ `<form>` bằng Inspect Element của trình duyệt rồi bấm Submit.
|
||||
- Hoặc ném `abort(419)`.
|
||||
- Kiểm tra xem có hiển thị nút **"Tải lại trang"** hay không.
|
||||
- [ ] **Test 429 Too Many Requests:**
|
||||
- Cấu hình tạm thời middleware `throttle:3,1` trên route chính và bấm F5 liên tục 4 lần.
|
||||
- Hoặc gọi `abort(429)`.
|
||||
- Kiểm tra thông báo lỗi.
|
||||
- [ ] **Test 500 Internal Server Error:**
|
||||
- Thêm một dòng code bị lỗi cú pháp cố ý trong Controller (ví dụ: gọi hàm không tồn tại `$this->nonExistentMethod()`).
|
||||
- Hoặc gọi `abort(500)`.
|
||||
- Đảm bảo giao diện trang 500 hiển thị thân thiện, không làm lộ stack trace kỹ thuật của code PHP.
|
||||
- [ ] **Test 503 Maintenance:**
|
||||
- Chạy lệnh `php artisan down` trong container Docker.
|
||||
- Tải lại trình duyệt để xem trang bảo trì 503 và nút **"Thử lại"**.
|
||||
- Chạy `php artisan up` để đưa website trở lại bình thường.
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'login' => 'Đăng nhập',
|
||||
'email' => 'Email',
|
||||
'email_placeholder' => 'Nhập email của bạn',
|
||||
'password' => 'Mật khẩu',
|
||||
'password_placeholder' => 'Nhập mật khẩu',
|
||||
'forgot_password' => 'Quên mật khẩu?',
|
||||
'system_name' => 'Hệ thống Thank Card',
|
||||
'developed_by' => 'Được phát triển bởi GMO-Z.com RUNSYSTEM',
|
||||
'failed' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại',
|
||||
'inactive' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên',
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'401_title' => 'Chưa đăng nhập',
|
||||
'401_message' => 'Bạn cần đăng nhập tài khoản trước khi<br>truy cập vào trang này.',
|
||||
|
||||
'403_title' => 'Không có quyền truy cập',
|
||||
'403_message' => 'Bạn không được phân quyền để truy cập<br>vào tài nguyên hoặc hành động này.',
|
||||
|
||||
'404_title' => 'Không tìm thấy trang',
|
||||
'404_message' => 'Trang bạn đang tìm kiếm không tồn tại hoặc<br>đã được di chuyển khỏi hệ thống.',
|
||||
|
||||
'419_title' => 'Phiên làm việc hết hạn',
|
||||
'419_message' => 'Trang đã hết hạn bảo mật do lâu không tương tác.<br>Vui lòng tải lại trang và thử lại.',
|
||||
|
||||
'429_title' => 'Yêu cầu quá giới hạn',
|
||||
'429_message' => 'Bạn đang gửi quá nhiều yêu cầu đến máy chủ.<br>Vui lòng đợi một lát rồi thử lại.',
|
||||
|
||||
'500_title' => 'Lỗi máy chủ nội bộ',
|
||||
'500_message' => 'Hệ thống đã gặp sự cố đột xuất máy chủ.<br>Chúng tôi đang kiểm tra và khắc phục sớm nhất.',
|
||||
|
||||
'503_title' => 'Hệ thống đang bảo trì',
|
||||
'503_message' => 'Hệ thống hiện đang được bảo trì nâng cấp định kỳ.<br>Xin vui lòng quay lại sau ít phút.',
|
||||
|
||||
'back_to_home' => 'Về trang chủ',
|
||||
'back_to_login' => 'Đi đến Đăng Nhập',
|
||||
'back' => 'Quay lại',
|
||||
'retry' => 'Thử lại',
|
||||
'reload' => 'Tải lại trang',
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'manage_users' => 'Quản lý User',
|
||||
'add_user' => 'Thêm User',
|
||||
'my_page' => 'My Page',
|
||||
'send_card' => 'Gửi Thank Card',
|
||||
'change_password' => 'Đổi mật khẩu',
|
||||
'logout' => 'Đăng xuất',
|
||||
'have_questions' => 'Bạn có thắc mắc?',
|
||||
'feedback' => 'Phản hồi',
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'password_change_success' => 'Đổi mật khẩu thành công!',
|
||||
'user_create_success' => 'Tạo user thành công!',
|
||||
'user_update_success' => 'Cập nhật thành công!',
|
||||
'user_deactivate_success' => 'Đã đánh dấu nhân viên nghỉ việc thành công!',
|
||||
'cards_reset_success' => 'Reset thẻ toàn hệ thống thành công!',
|
||||
'thank_card_send_success' => 'Đã gửi Thank card thành công!',
|
||||
|
||||
'error' => [
|
||||
'no_send_permission' => 'Bạn hiện không có quyền gửi Thank Card.',
|
||||
'not_enough_cards' => 'Số lượng card của bạn không đủ. Vui lòng liên hệ Admin.',
|
||||
'max_send_limit_template' => 'Một tháng bạn chỉ được gửi tối đa :max card cho một người. Bạn đã gửi :sent card cho nhân viên này.',
|
||||
'receiver_invalid' => 'Người nhận không tồn tại hoặc đã nghỉ việc.',
|
||||
]
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'required' => ':attribute không được để trống.',
|
||||
'email' => ':attribute không đúng định dạng.',
|
||||
'max' => [
|
||||
'numeric' => ':attribute không được lớn hơn :max.',
|
||||
'file' => ':attribute không được lớn hơn :max kilobytes.',
|
||||
'string' => ':attribute không được vượt quá :max ký tự.',
|
||||
'array' => ':attribute không được có nhiều hơn :max phần tử.',
|
||||
],
|
||||
'min' => [
|
||||
'numeric' => ':attribute phải tối thiểu là :min.',
|
||||
'file' => ':attribute phải tối thiểu là :min kilobytes.',
|
||||
'string' => ':attribute phải có ít nhất :min ký tự.',
|
||||
'array' => ':attribute phải có ít nhất :min phần tử.',
|
||||
],
|
||||
'unique' => ':attribute đã tồn tại trong hệ thống.',
|
||||
'confirmed' => 'Xác nhận :attribute không khớp.',
|
||||
'integer' => ':attribute phải là một số nguyên.',
|
||||
'exists' => ':attribute không tồn tại.',
|
||||
'boolean' => ':attribute phải là true hoặc false.',
|
||||
|
||||
'attributes' => [
|
||||
'mail' => 'Email',
|
||||
'password' => 'Mật khẩu',
|
||||
'msnv' => 'Mã số nhân viên',
|
||||
'role' => 'Vai trò',
|
||||
'num_card' => 'Số lượng thẻ',
|
||||
'flag_send' => 'Quyền gửi',
|
||||
'receiver' => 'Người nhận',
|
||||
'amount' => 'Số lượng',
|
||||
],
|
||||
];
|
||||
@@ -13,5 +13,9 @@
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"puppeteer": "^25.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||
<env name="CACHE_STORE" value="array" force="true"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="DB_URL" value="" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
|
After Width: | Height: | Size: 390 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 553 KiB |
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
// 1. Update app/Models/User.php
|
||||
$content = file_get_contents('app/Models/User.php');
|
||||
$constants = "
|
||||
const ROLE_MEMBER = 0;
|
||||
const ROLE_ADMIN = 1;
|
||||
|
||||
const STATUS_INACTIVE = 0;
|
||||
const STATUS_ACTIVE = 1;
|
||||
|
||||
const FLAG_SEND_DISABLED = 0;
|
||||
const FLAG_SEND_ENABLED = 1;
|
||||
|
||||
const FIRST_LOGIN_FALSE = 0;
|
||||
const FIRST_LOGIN_TRUE = 1;
|
||||
";
|
||||
$content = str_replace('use HasFactory, Notifiable;', "use HasFactory, Notifiable;\n" . $constants, $content);
|
||||
file_put_contents('app/Models/User.php', $content);
|
||||
|
||||
// 2. Update app/Models/Administration.php
|
||||
$content = file_get_contents('app/Models/Administration.php');
|
||||
$constants = "
|
||||
const MAX_SEND_CARD_PER_MONTH = 5;
|
||||
";
|
||||
$content = str_replace('use HasFactory;', "use HasFactory;\n" . $constants, $content);
|
||||
file_put_contents('app/Models/Administration.php', $content);
|
||||
|
||||
// 3. Update app/Http/Middleware/AdminMiddleware.php
|
||||
$content = file_get_contents('app/Http/Middleware/AdminMiddleware.php');
|
||||
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
|
||||
$content = str_replace('Auth::user()->role == 1', 'Auth::user()->role == User::ROLE_ADMIN', $content);
|
||||
$content = str_replace('Auth::user()->status == 1', 'Auth::user()->status == User::STATUS_ACTIVE', $content);
|
||||
file_put_contents('app/Http/Middleware/AdminMiddleware.php', $content);
|
||||
|
||||
// 4. Update app/Http/Middleware/MemberMiddleware.php
|
||||
$content = file_get_contents('app/Http/Middleware/MemberMiddleware.php');
|
||||
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
|
||||
$content = str_replace('Auth::user()->role == 0', 'Auth::user()->role == User::ROLE_MEMBER', $content);
|
||||
$content = str_replace('Auth::user()->status == 1', 'Auth::user()->status == User::STATUS_ACTIVE', $content);
|
||||
file_put_contents('app/Http/Middleware/MemberMiddleware.php', $content);
|
||||
|
||||
// 5. Update app/Http/Middleware/ForceChangePasswordMiddleware.php
|
||||
$content = file_get_contents('app/Http/Middleware/ForceChangePasswordMiddleware.php');
|
||||
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
|
||||
$content = str_replace('Auth::user()->first_login == 1', 'Auth::user()->first_login == User::FIRST_LOGIN_TRUE', $content);
|
||||
file_put_contents('app/Http/Middleware/ForceChangePasswordMiddleware.php', $content);
|
||||
|
||||
// 6. Update app/Http/Controllers/AuthController.php
|
||||
$content = file_get_contents('app/Http/Controllers/AuthController.php');
|
||||
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
|
||||
$content = str_replace('$user->status != 1', '$user->status != User::STATUS_ACTIVE', $content);
|
||||
$content = str_replace('$user->first_login == 1', '$user->first_login == User::FIRST_LOGIN_TRUE', $content);
|
||||
$content = str_replace('$user->role == 1', '$user->role == User::ROLE_ADMIN', $content);
|
||||
file_put_contents('app/Http/Controllers/AuthController.php', $content);
|
||||
|
||||
// 7. Update app/Http/Controllers/AdminController.php
|
||||
$content = file_get_contents('app/Http/Controllers/AdminController.php');
|
||||
$content = str_replace("where('status', 1)", "where('status', User::STATUS_ACTIVE)", $content);
|
||||
$content = str_replace("'role' => 'required|in:0,1'", "'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN", $content);
|
||||
$content = str_replace("'status' => 1", "'status' => User::STATUS_ACTIVE", $content);
|
||||
$content = str_replace("'flag_send' => 0", "'flag_send' => User::FLAG_SEND_DISABLED", $content);
|
||||
$content = str_replace("'first_login' => 1", "'first_login' => User::FIRST_LOGIN_TRUE", $content);
|
||||
$content = str_replace("\$request->has('flag_send') ? 1 : 0", "\$request->has('flag_send') ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED", $content);
|
||||
$content = str_replace("\$user->status = 0", "\$user->status = User::STATUS_INACTIVE", $content);
|
||||
file_put_contents('app/Http/Controllers/AdminController.php', $content);
|
||||
|
||||
// 8. Update app/Http/Controllers/UserController.php
|
||||
$content = file_get_contents('app/Http/Controllers/UserController.php');
|
||||
$content = str_replace("where('status', 1)", "where('status', User::STATUS_ACTIVE)", $content);
|
||||
$content = str_replace("max:5'", "max:' . Administration::MAX_SEND_CARD_PER_MONTH", $content);
|
||||
$content = str_replace("\$sender->flag_send == 0", "\$sender->flag_send == User::FLAG_SEND_DISABLED", $content);
|
||||
$content = str_replace("> 5)", "> Administration::MAX_SEND_CARD_PER_MONTH)", $content);
|
||||
$content = str_replace("tối đa 5 card", 'tối đa " . Administration::MAX_SEND_CARD_PER_MONTH . " card', $content);
|
||||
$content = str_replace("\$user->first_login = 0", "\$user->first_login = User::FIRST_LOGIN_FALSE", $content);
|
||||
$content = str_replace("\$user->role == 1", "\$user->role == User::ROLE_ADMIN", $content);
|
||||
file_put_contents('app/Http/Controllers/UserController.php', $content);
|
||||
|
||||
// 9. Update app/Console/Commands/ResetCardsCommand.php
|
||||
$content = file_get_contents('app/Console/Commands/ResetCardsCommand.php');
|
||||
$content = str_replace("where('status', 1)", "where('status', User::STATUS_ACTIVE)", $content);
|
||||
file_put_contents('app/Console/Commands/ResetCardsCommand.php', $content);
|
||||
|
||||
// 10. Update resources/views/layouts/app.blade.php
|
||||
$content = file_get_contents('resources/views/layouts/app.blade.php');
|
||||
$content = str_replace("Auth::user()->role == 1", "Auth::user()->role == \App\Models\User::ROLE_ADMIN", $content);
|
||||
file_put_contents('resources/views/layouts/app.blade.php', $content);
|
||||
|
||||
// 11. Update resources/views/admin/users/create.blade.php
|
||||
$content = file_get_contents('resources/views/admin/users/create.blade.php');
|
||||
$content = str_replace('value="0"', 'value="{{ \App\Models\User::ROLE_MEMBER }}"', $content);
|
||||
$content = str_replace('value="1"', 'value="{{ \App\Models\User::ROLE_ADMIN }}"', $content);
|
||||
$content = str_replace("old('role') == '0'", "old('role') == \App\Models\User::ROLE_MEMBER", $content);
|
||||
$content = str_replace("old('role') == '1'", "old('role') == \App\Models\User::ROLE_ADMIN", $content);
|
||||
file_put_contents('resources/views/admin/users/create.blade.php', $content);
|
||||
|
||||
// 12. Update resources/views/admin/users/edit.blade.php
|
||||
$content = file_get_contents('resources/views/admin/users/edit.blade.php');
|
||||
$content = str_replace("\$user->role == 1", "\$user->role == \App\Models\User::ROLE_ADMIN", $content);
|
||||
file_put_contents('resources/views/admin/users/edit.blade.php', $content);
|
||||
|
||||
// 13. Update resources/views/user/change_password.blade.php
|
||||
$content = file_get_contents('resources/views/user/change_password.blade.php');
|
||||
$content = str_replace("Auth::user()->first_login == 1", "Auth::user()->first_login == \App\Models\User::FIRST_LOGIN_TRUE", $content);
|
||||
file_put_contents('resources/views/user/change_password.blade.php', $content);
|
||||
|
||||
echo "Refactored successfully!";
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-full h-full">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-full h-full">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
|
After Width: | Height: | Size: 201 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
|
After Width: | Height: | Size: 200 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |