Add QR Plus REST API for listing, creating, and updating codes.
Deploy Ladill QR Plus / deploy (push) Successful in 29s
Deploy Ladill QR Plus / deploy (push) Successful in 29s
Exposes /api/v1/qr-codes with analytics, team account header support, and qr:read/qr:write token abilities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MeController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Qr\QrAnalyticsService;
|
||||||
|
use App\Services\Qr\QrCodeManagerService;
|
||||||
|
use App\Support\Qr\QrTypeCatalog;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class QrCodeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private QrCodeManagerService $manager,
|
||||||
|
private QrAnalyticsService $analytics,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$codes = $this->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<QrCode> */
|
||||||
|
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<string, mixed> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class DeveloperController extends Controller
|
|||||||
'name' => ['required', 'string', 'max:60'],
|
'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')
|
return redirect()->route('account.developers')
|
||||||
->with('new_token', $token->plainTextToken)
|
->with('new_token', $token->plainTextToken)
|
||||||
|
|||||||
@@ -13,18 +13,27 @@ class SetActingAccount
|
|||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
if ($user = $request->user()) {
|
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)) {
|
if (! $user->canAccessAccount($accountId)) {
|
||||||
$accountId = $user->id;
|
$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);
|
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
|
||||||
|
|
||||||
$request->attributes->set('actingAccount', $account);
|
$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);
|
return $next($request);
|
||||||
|
|||||||
@@ -57,10 +57,23 @@
|
|||||||
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
||||||
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
||||||
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
||||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/me \
|
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/qr-codes \
|
||||||
-H "Authorization: Bearer <your-token>" \
|
-H "Authorization: Bearer <your-token>" \
|
||||||
-H "Accept: application/json"</code></pre>
|
-H "Accept: application/json"</code></pre>
|
||||||
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>. QR code management endpoints coming soon.</p>
|
<p class="mt-3 text-[11px] text-slate-400">Endpoints:</p>
|
||||||
|
<ul class="mt-1.5 space-y-1 text-[11px] text-slate-400">
|
||||||
|
<li><span class="font-mono text-slate-300">GET /me</span> — token user and acting account</li>
|
||||||
|
<li><span class="font-mono text-slate-300">GET /qr-codes</span> — list your codes</li>
|
||||||
|
<li><span class="font-mono text-slate-300">GET /qr-codes/{id}</span> — code details</li>
|
||||||
|
<li><span class="font-mono text-slate-300">GET /qr-codes/{id}/analytics</span> — scan stats</li>
|
||||||
|
<li><span class="font-mono text-slate-300">POST /qr-codes</span> — create (url, link_list, wifi, business, app)</li>
|
||||||
|
<li><span class="font-mono text-slate-300">PATCH /qr-codes/{id}</span> — update label, content, pause/resume</li>
|
||||||
|
</ul>
|
||||||
|
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>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"}'</code></pre>
|
||||||
|
<p class="mt-3 text-[11px] text-slate-500">Team access: pass <span class="font-mono text-slate-300">X-Ladill-Account: <owner-user-id></span>. PDF codes need the web app. Regenerate tokens to get write access if yours only has read.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-user-layout>
|
</x-user-layout>
|
||||||
|
|||||||
+11
-2
@@ -1,6 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use App\Http\Controllers\Api\MeController;
|
||||||
|
use App\Http\Controllers\Api\QrCodeController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('auth:sanctum')->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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user