diff --git a/app/Http/Controllers/Api/MeController.php b/app/Http/Controllers/Api/MeController.php new file mode 100644 index 0000000..7791803 --- /dev/null +++ b/app/Http/Controllers/Api/MeController.php @@ -0,0 +1,32 @@ +user(); + $account = ladill_account(); + + return response()->json([ + 'data' => [ + 'id' => $user->id, + 'public_id' => $user->public_id, + 'name' => $user->name, + 'email' => $user->email, + 'acting_account' => [ + 'id' => $account->id, + 'public_id' => $account->public_id, + 'name' => $account->name, + 'email' => $account->email, + ], + 'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/QrCodeController.php b/app/Http/Controllers/Api/QrCodeController.php new file mode 100644 index 0000000..fca05f6 --- /dev/null +++ b/app/Http/Controllers/Api/QrCodeController.php @@ -0,0 +1,153 @@ +accountQuery() + ->latest() + ->get() + ->map(fn (QrCode $code) => $this->present($code)); + + return response()->json(['data' => $codes]); + } + + public function show(QrCode $qrCode): JsonResponse + { + $this->ensurePlusCode($qrCode); + $this->authorize('view', $qrCode); + + return response()->json(['data' => $this->present($qrCode, detailed: true)]); + } + + public function analytics(QrCode $qrCode): JsonResponse + { + $this->ensurePlusCode($qrCode); + $this->authorize('view', $qrCode); + + return response()->json([ + 'data' => [ + 'summary' => $this->analytics->summaryFor($qrCode), + 'daily_scans' => $this->analytics->dailyScans($qrCode, 30)->values(), + 'devices' => $this->analytics->breakdown($qrCode, 'device_type'), + 'browsers' => $this->analytics->breakdown($qrCode, 'browser'), + ], + ]); + } + + public function store(Request $request): JsonResponse + { + abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.'); + + $validated = $request->validate([ + 'label' => ['required', 'string', 'max:120'], + 'type' => ['required', 'in:'.implode(',', QrTypeCatalog::keys())], + 'destination_url' => ['nullable', 'url', 'max:2048'], + 'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + if ($validated['type'] === QrCode::TYPE_DOCUMENT) { + return response()->json([ + 'message' => 'PDF QR codes must be created in the QR Plus app (file upload required).', + ], 422); + } + + try { + $qrCode = $this->manager->create(ladill_account(), array_merge( + $request->all(), + $validated, + )); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $this->present($qrCode->fresh(), detailed: true)], 201); + } + + public function update(Request $request, QrCode $qrCode): JsonResponse + { + abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.'); + + $this->ensurePlusCode($qrCode); + $this->authorize('update', $qrCode); + + $request->validate([ + 'label' => ['sometimes', 'string', 'max:120'], + 'destination_url' => ['nullable', 'url', 'max:2048'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + if ($qrCode->isDocumentType() && $request->hasFile('document')) { + return response()->json([ + 'message' => 'Replace PDF files in the QR Plus app.', + ], 422); + } + + try { + $updated = $this->manager->update($qrCode, $request->all()); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['data' => $this->present($updated->fresh(), detailed: true)]); + } + + /** @return \Illuminate\Database\Eloquent\Builder */ + private function accountQuery() + { + return QrCode::query() + ->where('user_id', ladill_account()->id) + ->whereIn('type', QrTypeCatalog::plusTypes()); + } + + private function ensurePlusCode(QrCode $qrCode): void + { + abort_unless( + $qrCode->user_id === ladill_account()->id && QrTypeCatalog::isValid($qrCode->type), + 404, + ); + } + + /** @return array */ + private function present(QrCode $qrCode, bool $detailed = false): array + { + $data = [ + 'id' => $qrCode->id, + 'label' => $qrCode->label, + 'type' => $qrCode->type, + 'type_label' => $qrCode->typeLabel(), + 'short_code' => $qrCode->short_code, + 'public_url' => $qrCode->publicUrl(), + 'is_active' => $qrCode->is_active, + 'scans_total' => $qrCode->scans_total, + 'unique_scans_total' => $qrCode->unique_scans_total, + 'last_scanned_at' => $qrCode->last_scanned_at?->toIso8601String(), + 'created_at' => $qrCode->created_at?->toIso8601String(), + 'updated_at' => $qrCode->updated_at?->toIso8601String(), + ]; + + if ($detailed) { + $data['destination_url'] = $qrCode->destination_url; + $data['content'] = $qrCode->content(); + } + + return $data; + } +} diff --git a/app/Http/Controllers/Qr/DeveloperController.php b/app/Http/Controllers/Qr/DeveloperController.php index bea5659..98b53a5 100644 --- a/app/Http/Controllers/Qr/DeveloperController.php +++ b/app/Http/Controllers/Qr/DeveloperController.php @@ -29,7 +29,7 @@ class DeveloperController extends Controller 'name' => ['required', 'string', 'max:60'], ]); - $token = $request->user()->createToken($data['name'], ['qr:read']); + $token = $request->user()->createToken($data['name'], ['qr:read', 'qr:write']); return redirect()->route('account.developers') ->with('new_token', $token->plainTextToken) diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php index f1c5732..c9f4c1a 100644 --- a/app/Http/Middleware/SetActingAccount.php +++ b/app/Http/Middleware/SetActingAccount.php @@ -13,18 +13,27 @@ class SetActingAccount public function handle(Request $request, Closure $next): Response { if ($user = $request->user()) { - $accountId = (int) $request->session()->get('ladill_account', $user->id); + if ($request->is('api/*')) { + $accountId = (int) ($request->header('X-Ladill-Account') ?: $user->id); + } else { + $accountId = (int) $request->session()->get('ladill_account', $user->id); + } if (! $user->canAccessAccount($accountId)) { $accountId = $user->id; - $request->session()->put('ladill_account', $accountId); + if (! $request->is('api/*')) { + $request->session()->put('ladill_account', $accountId); + } } $account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user); $request->attributes->set('actingAccount', $account); - View::share('actingAccount', $account); - View::share('accessibleAccounts', $user->accessibleAccounts()); + + if (! $request->is('api/*')) { + View::share('actingAccount', $account); + View::share('accessibleAccounts', $user->accessibleAccounts()); + } } return $next($request); diff --git a/resources/views/qr/account/developers.blade.php b/resources/views/qr/account/developers.blade.php index fa2782b..bd64bc6 100644 --- a/resources/views/qr/account/developers.blade.php +++ b/resources/views/qr/account/developers.blade.php @@ -57,10 +57,23 @@

Quick start

Authenticate with a Bearer token. Base URL:

{{ $apiBase }} -
curl {{ $apiBase }}/me \
+            
curl {{ $apiBase }}/qr-codes \
   -H "Authorization: Bearer <your-token>" \
   -H "Accept: application/json"
-

Endpoints: GET /me. QR code management endpoints coming soon.

+

Endpoints:

+
    +
  • GET /me — token user and acting account
  • +
  • GET /qr-codes — list your codes
  • +
  • GET /qr-codes/{id} — code details
  • +
  • GET /qr-codes/{id}/analytics — scan stats
  • +
  • POST /qr-codes — create (url, link_list, wifi, business, app)
  • +
  • PATCH /qr-codes/{id} — update label, content, pause/resume
  • +
+
curl -X POST {{ $apiBase }}/qr-codes \
+  -H "Authorization: Bearer <your-token>" \
+  -H "Content-Type: application/json" \
+  -d '{"label":"Homepage","type":"url","destination_url":"https://example.com"}'
+

Team access: pass X-Ladill-Account: <owner-user-id>. PDF codes need the web app. Regenerate tokens to get write access if yours only has read.

diff --git a/routes/api.php b/routes/api.php index d093a77..abcace7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,15 @@ get('/v1/me', fn (Request $request) => $request->user()); +Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () { + Route::get('/me', MeController::class); + + Route::get('/qr-codes', [QrCodeController::class, 'index']); + Route::post('/qr-codes', [QrCodeController::class, 'store']); + Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode'); + Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode'); + Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode'); +});