feat: add app and database modules
This commit is contained in:
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use App\Models\DeviceToken;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DeviceTokenController extends Controller
|
||||
{
|
||||
#[OA\Post(
|
||||
path: '/v1/devices/register',
|
||||
operationId: 'registerDevice',
|
||||
tags: ['Push Notifications'],
|
||||
summary: 'Register a device token for push notifications',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['token', 'platform'],
|
||||
properties: [
|
||||
new OA\Property(property: 'token', type: 'string', description: 'FCM device token'),
|
||||
new OA\Property(property: 'platform', type: 'string', enum: ['ios', 'android', 'web']),
|
||||
new OA\Property(property: 'device_name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'app_version', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Device registered'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function register(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required|string',
|
||||
'platform' => 'required|in:ios,android,web',
|
||||
'device_name' => 'nullable|string|max:100',
|
||||
'app_version' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$deviceToken = DeviceToken::updateOrCreate(
|
||||
['token' => $request->input('token')],
|
||||
[
|
||||
'user_id' => $request->user()->id,
|
||||
'platform' => $request->input('platform'),
|
||||
'device_name' => $request->input('device_name'),
|
||||
'app_version' => $request->input('app_version'),
|
||||
'last_used_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return ApiResponse::success(['device_id' => $deviceToken->id], 'Device registered successfully');
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/v1/devices/unregister',
|
||||
operationId: 'unregisterDevice',
|
||||
tags: ['Push Notifications'],
|
||||
summary: 'Remove a device token',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['token'],
|
||||
properties: [new OA\Property(property: 'token', type: 'string')]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Device unregistered'),
|
||||
]
|
||||
)]
|
||||
public function unregister(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required|string',
|
||||
]);
|
||||
|
||||
DeviceToken::where('token', $request->input('token'))
|
||||
->where('user_id', $request->user()->id)
|
||||
->delete();
|
||||
|
||||
return ApiResponse::success(null, 'Device unregistered successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class HealthController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/health',
|
||||
operationId: 'healthCheck',
|
||||
tags: ['System'],
|
||||
summary: 'Application health check',
|
||||
description: 'Returns status of database, Redis, storage, and queue. Returns 503 if any check fails.',
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All systems healthy',
|
||||
content: new OA\JsonContent(properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'healthy'),
|
||||
new OA\Property(property: 'timestamp', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'checks', type: 'object'),
|
||||
])
|
||||
),
|
||||
new OA\Response(response: 503, description: 'One or more checks degraded'),
|
||||
]
|
||||
)]
|
||||
public function check()
|
||||
{
|
||||
$checks = [
|
||||
'database' => $this->checkDatabase(),
|
||||
'redis' => $this->checkRedis(),
|
||||
'storage' => $this->checkStorage(),
|
||||
'queue' => $this->checkQueue(),
|
||||
];
|
||||
|
||||
$hasFailure = collect($checks)->contains(fn ($c) => $c['status'] === 'fail');
|
||||
$allOk = collect($checks)->every(fn ($c) => $c['status'] === 'ok');
|
||||
|
||||
return response()->json([
|
||||
'status' => $allOk ? 'healthy' : ($hasFailure ? 'degraded' : 'warn'),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'checks' => $checks,
|
||||
], $hasFailure ? 503 : 200);
|
||||
}
|
||||
|
||||
private function checkDatabase(): array
|
||||
{
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
|
||||
return ['status' => 'ok', 'latency_ms' => $this->measure(fn () => DB::select('SELECT 1'))];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkRedis(): array
|
||||
{
|
||||
try {
|
||||
$latency = $this->measure(fn () => Cache::store('redis')->put('health_check', true, 5));
|
||||
|
||||
return ['status' => 'ok', 'latency_ms' => $latency];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkStorage(): array
|
||||
{
|
||||
try {
|
||||
$free = disk_free_space(storage_path());
|
||||
$total = disk_total_space(storage_path());
|
||||
|
||||
if ($free === false || $total === false || $total === 0.0) {
|
||||
return ['status' => 'fail', 'error' => 'Unable to read disk space.'];
|
||||
}
|
||||
|
||||
$usedPct = round((($total - $free) / $total) * 100, 1);
|
||||
|
||||
return [
|
||||
'status' => $usedPct < 90 ? 'ok' : 'warn',
|
||||
'used_pct' => $usedPct,
|
||||
'free_gb' => round($free / 1073741824, 2),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkQueue(): array
|
||||
{
|
||||
try {
|
||||
$size = Queue::size('default');
|
||||
|
||||
return ['status' => 'ok', 'pending_jobs' => $size];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'unknown', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function measure(callable $fn): int
|
||||
{
|
||||
$start = hrtime(true);
|
||||
$fn();
|
||||
|
||||
return (int) round((hrtime(true) - $start) / 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\MobileConfig\MobileConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class MobileConfigController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MobileConfigService $service
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/mobile/sync',
|
||||
operationId: 'getMobileConfig',
|
||||
tags: ['Mobile'],
|
||||
summary: 'Get mobile app configuration',
|
||||
description: 'Returns cached mobile configuration including theme, flags, and assets.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'p',
|
||||
in: 'query',
|
||||
description: 'Platform (ios/android)',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'v',
|
||||
in: 'query',
|
||||
description: 'App Version',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Successful operation',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'success'),
|
||||
new OA\Property(property: 'version', type: 'string', example: '1.1.0'),
|
||||
new OA\Property(property: 'data', type: 'object'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function sync(Request $request)
|
||||
{
|
||||
$config = $this->service->all();
|
||||
$etag = md5(json_encode($config));
|
||||
|
||||
if ($request->hasHeader('If-None-Match') && $request->header('If-None-Match') === $etag) {
|
||||
return response()->json([], 304);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'version' => '1.1.0',
|
||||
'last_updated' => now()->toIso8601String(),
|
||||
'data' => $config,
|
||||
])->header('ETag', $etag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MobileLogController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'level' => 'required|string|in:debug,info,warning,error,critical',
|
||||
'message' => 'required|string',
|
||||
'context' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$context = [
|
||||
'timestamp' => now()->format('Y-m-d H:i:s'),
|
||||
'user_id' => auth()->id() ?? 'guest',
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'context' => $request->context,
|
||||
];
|
||||
|
||||
$message = $request->message.' Context: '.json_encode($context);
|
||||
|
||||
Log::channel('mobile')->log($request->level, $message);
|
||||
|
||||
return ApiResponse::success(null, 'Log recorded');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use App\Mail\TwoFactorOtp;
|
||||
use App\Services\Auth\OtpService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class OtpController extends Controller
|
||||
{
|
||||
public function __construct(protected OtpService $otpService) {}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/otp/send',
|
||||
operationId: 'otpSend',
|
||||
tags: ['OTP'],
|
||||
summary: 'Send a 6-digit OTP to the given email',
|
||||
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: 'OTP sent'),
|
||||
new OA\Response(response: 500, description: 'Mail delivery failed'),
|
||||
]
|
||||
)]
|
||||
public function send(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$email = $request->email;
|
||||
$code = $this->otpService->generate($email);
|
||||
|
||||
try {
|
||||
Mail::to($email)->send(new TwoFactorOtp(
|
||||
otp: $code,
|
||||
ipAddress: $request->ip(),
|
||||
userAgent: $request->userAgent(),
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('OTP send failed', ['email' => $email, 'error' => $e->getMessage()]);
|
||||
|
||||
return ApiResponse::serverError('Failed to send OTP. Please try again later.');
|
||||
}
|
||||
|
||||
return ApiResponse::success(null, 'OTP has been sent to your email');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/otp/verify',
|
||||
operationId: 'otpVerify',
|
||||
tags: ['OTP'],
|
||||
summary: 'Verify an OTP code',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['email', 'code'],
|
||||
properties: [
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
||||
new OA\Property(property: 'code', type: 'string', minLength: 6, maxLength: 6, example: '123456'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'OTP verified'),
|
||||
new OA\Response(response: 422, description: 'Invalid or expired OTP'),
|
||||
]
|
||||
)]
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'code' => 'required|string|size:6',
|
||||
]);
|
||||
|
||||
if (! $this->otpService->verify($request->input('email'), $request->input('code'))) {
|
||||
return ApiResponse::error('Invalid or expired OTP code', 422);
|
||||
}
|
||||
|
||||
return ApiResponse::success(null, 'OTP verified successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Info(
|
||||
version: '1.1.0',
|
||||
title: 'Premium Template API',
|
||||
description: 'Unified API for Mobile and Web Integration. Authenticated endpoints require a Bearer token from POST /v1/login.'
|
||||
)]
|
||||
#[OA\Contact(email: 'admin@example.com')]
|
||||
#[OA\Server(url: 'http://localhost:8000/api', description: 'Local')]
|
||||
#[OA\Server(url: 'https://yourdomain.com/api', description: 'Production')]
|
||||
#[OA\SecurityScheme(
|
||||
securityScheme: 'sanctum',
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'Token',
|
||||
description: 'Enter the token returned by POST /v1/login'
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'User',
|
||||
properties: [
|
||||
new OA\Property(property: 'id', type: 'integer'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
||||
new OA\Property(property: 'is_active', type: 'boolean'),
|
||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||
]
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'ApiSuccess',
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'success'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'data', type: 'object', nullable: true),
|
||||
]
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'ApiError',
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'error'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'errors', type: 'object', nullable: true),
|
||||
]
|
||||
)]
|
||||
class Swagger {}
|
||||
Reference in New Issue
Block a user