feat: add app and database modules

This commit is contained in:
2026-05-21 16:05:11 +07:00
parent 37b7e783f5
commit fad70d096b
212 changed files with 23901 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Helpers\ApiResponse;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Auth\PasswordPolicyService;
use App\Services\MobileConfig\MobileConfigService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
use Spatie\Permission\Models\Role;
class AuthController extends Controller
{
public function __construct(
protected MobileConfigService $mobileConfig
) {}
#[OA\Post(
path: '/v1/login',
operationId: 'login',
tags: ['Auth'],
summary: 'Authenticate a user',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['email', 'password'],
properties: [
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'user@example.com'),
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'secret'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Login successful',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'status', type: 'string', example: 'success'),
new OA\Property(property: 'data', type: 'object', properties: [
new OA\Property(property: 'user', ref: '#/components/schemas/User'),
new OA\Property(property: 'token', type: 'string'),
]),
])
),
new OA\Response(response: 401, description: 'Invalid credentials'),
new OA\Response(response: 403, description: 'Account inactive or role not permitted'),
]
)]
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$config = $this->mobileConfig->all();
$maxAttempts = $config['security_auth']['login_max_attempts'] ?? 5;
$throttleKey = 'login_attempt_'.$request->ip().'_'.$request->email;
if (RateLimiter::tooManyAttempts($throttleKey, $maxAttempts)) {
$seconds = RateLimiter::availableIn($throttleKey);
return ApiResponse::error("Too many login attempts. Please try again in {$seconds} seconds.", 429);
}
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
RateLimiter::hit($throttleKey, 60); // Lock for 60 seconds if limit reached
return ApiResponse::unauthorized('The provided credentials are incorrect.');
}
RateLimiter::clear($throttleKey);
if (isset($user->is_active) && ! $user->is_active) {
return ApiResponse::forbidden('Your account is currently inactive. Please contact support.');
}
if (class_exists(Role::class)) {
$allowedRoles = ['User', 'Administrator', 'Developer'];
if (! $user->hasAnyRole($allowedRoles)) {
return ApiResponse::forbidden('Access denied. Your role does not have permission to access the mobile application.');
}
}
$token = $user->createToken('mobile-app')->plainTextToken;
return ApiResponse::success(['user' => $user, 'token' => $token], 'Login successful');
}
#[OA\Post(
path: '/v1/register',
operationId: 'register',
tags: ['Auth'],
summary: 'Register a new user',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'email', 'password'],
properties: [
new OA\Property(property: 'name', type: 'string', example: 'John Doe'),
new OA\Property(property: 'email', type: 'string', format: 'email'),
new OA\Property(property: 'password', type: 'string', minLength: 8),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Registration successful'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function register(Request $request)
{
$config = $this->mobileConfig->all();
// 1. Check if registration is enabled in Global Mobile Settings
if (! ($config['features']['enable_registration'] ?? true)) {
return ApiResponse::error('Registration is currently disabled by administrator.', 403);
}
// 2. Check if OTP is required
if (($config['features']['require_otp_registration'] ?? false) && ! $request->has('otp_token')) {
return ApiResponse::error('OTP verification is required for registration.', 403);
}
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => ['required', 'string', PasswordPolicyService::getRules()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'is_active' => true,
]);
if (class_exists(Role::class)) {
$user->assignRole('User');
}
$token = $user->createToken('mobile-app')->plainTextToken;
return ApiResponse::created(['user' => $user, 'token' => $token], 'Registration successful');
}
#[OA\Post(
path: '/v1/logout',
operationId: 'logout',
tags: ['Auth'],
summary: 'Revoke current access token',
security: [['sanctum' => []]],
responses: [
new OA\Response(response: 200, description: 'Logged out successfully'),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return ApiResponse::success(null, 'Logged out successfully');
}
#[OA\Post(
path: '/v1/forgot-password',
operationId: 'forgotPassword',
tags: ['Auth'],
summary: 'Send password reset link',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['email'],
properties: [new OA\Property(property: 'email', type: 'string', format: 'email')]
)
),
responses: [
new OA\Response(response: 200, description: 'Reset link sent'),
new OA\Response(response: 422, description: 'Email not found or throttled'),
]
)]
public function forgotPassword(Request $request)
{
$request->validate([
'email' => 'required|email',
]);
$status = Password::sendResetLink($request->only('email'));
if ($status === Password::RESET_LINK_SENT) {
return ApiResponse::success(null, __($status));
}
return ApiResponse::error(__($status), 422);
}
#[OA\Post(
path: '/v1/profile/update',
operationId: 'updateProfile',
tags: ['Profile'],
summary: 'Update authenticated user profile',
security: [['sanctum' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'email'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'email', type: 'string', format: 'email'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Profile updated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function updateProfile(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email,'.Auth::id(),
]);
$user = Auth::user();
$user->update([
'name' => $request->name,
'email' => $request->email,
]);
return ApiResponse::success(['user' => $user->fresh()], 'Profile updated successfully');
}
#[OA\Post(
path: '/v1/profile/avatar',
operationId: 'updateAvatar',
tags: ['Profile'],
summary: 'Upload user avatar',
security: [['sanctum' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'multipart/form-data',
schema: new OA\Schema(properties: [
new OA\Property(property: 'avatar', type: 'string', format: 'binary'),
])
)
),
responses: [
new OA\Response(response: 200, description: 'Avatar updated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function updateAvatar(Request $request)
{
$request->validate([
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:10240',
]);
$user = Auth::user();
if ($request->hasFile('avatar')) {
$user->clearMediaCollection('avatar');
$media = $user->addMediaFromRequest('avatar')->toMediaCollection('avatar');
return ApiResponse::success([
'avatar_url' => $media->getFullUrl(),
'user' => $user->fresh(),
], 'Avatar updated successfully');
}
return ApiResponse::error('No file uploaded', 400);
}
#[OA\Get(
path: '/v1/dashboard',
operationId: 'getDashboard',
tags: ['Dashboard'],
summary: 'Get dashboard summary data',
security: [['sanctum' => []]],
responses: [
new OA\Response(response: 200, description: 'Dashboard data'),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function getDashboardData(Request $request)
{
$user = Auth::user();
$cacheKey = "user_dashboard_v2_{$user->id}";
$data = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($user) {
$transactions = Transaction::where('user_id', $user->id)->latest()->take(10)->get();
$totalBalance = Transaction::where('user_id', $user->id)
->selectRaw("SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END) as balance")
->value('balance') ?? 0;
return [
'balance' => number_format($totalBalance, 2),
'transactions' => $transactions,
'stats' => [
'income' => Transaction::where('user_id', $user->id)->where('type', 'income')->sum('amount'),
'expense' => Transaction::where('user_id', $user->id)->where('type', 'expense')->sum('amount'),
],
];
});
return ApiResponse::success($data);
}
#[OA\Post(
path: '/v1/profile/password',
operationId: 'updatePassword',
tags: ['Profile'],
summary: 'Change authenticated user password',
security: [['sanctum' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['current_password', 'password', 'password_confirmation'],
properties: [
new OA\Property(property: 'current_password', type: 'string'),
new OA\Property(property: 'password', type: 'string', minLength: 8),
new OA\Property(property: 'password_confirmation', type: 'string'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Password updated'),
new OA\Response(response: 422, description: 'Current password incorrect'),
]
)]
public function updatePassword(Request $request)
{
$request->validate([
'current_password' => 'required',
'password' => 'required|string|min:8|confirmed',
]);
$user = Auth::user();
if (! Hash::check($request->current_password, $user->password)) {
return ApiResponse::error('The current password you entered is incorrect.', 422);
}
try {
PasswordPolicyService::checkHistory($user, $request->password);
} catch (ValidationException $e) {
return ApiResponse::error($e->errors()['password'][0] ?? 'Password does not meet policy requirements.', 422);
}
$passwordHash = Hash::make($request->password);
$user->update(['password' => $passwordHash]);
PasswordPolicyService::recordPasswordChange($user, $passwordHash);
return ApiResponse::success(null, 'Password updated successfully.');
}
#[OA\Delete(
path: '/v1/profile/delete',
operationId: 'deleteAccount',
tags: ['Profile'],
summary: 'Permanently delete authenticated user account',
security: [['sanctum' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['password'],
properties: [
new OA\Property(property: 'password', type: 'string', description: 'Current password to confirm deletion'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Account deleted'),
new OA\Response(response: 422, description: 'Password incorrect or missing'),
]
)]
public function deleteAccount(Request $request)
{
$request->validate([
'password' => 'required|string',
]);
$user = Auth::user();
if (! Hash::check($request->password, $user->password)) {
return ApiResponse::error('The password you entered is incorrect.', 422);
}
$user->tokens()->delete();
$user->delete();
return ApiResponse::success(null, 'Your account has been deleted permanently.');
}
#[OA\Get(
path: '/v1/user',
operationId: 'getUser',
tags: ['Auth'],
summary: 'Get authenticated user',
security: [['sanctum' => []]],
responses: [
new OA\Response(response: 200, description: 'User object'),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function user(Request $request)
{
return ApiResponse::success(['user' => $request->user()]);
}
#[OA\Get(
path: '/v1/app-config',
operationId: 'getAppConfig',
tags: ['Config'],
summary: 'Get public app configuration (branding, taglines)',
responses: [
new OA\Response(response: 200, description: 'App config'),
]
)]
public function getAppConfig()
{
return ApiResponse::success([
'logo' => asset(get_setting('app_logo', 'assets/img/logo.png')),
'tagline1' => get_setting('app_tagline1', 'Welcome'),
'tagline2' => strip_tags(get_setting('app_tagline2', 'Manage your assets efficiently')),
'footer' => get_setting('footer_text', '© 2026 Your App'),
]);
}
}