feat(api): mobile API Milestone 1+2 — Sanctum auth + offline sync vertical slice

Milestone 1 (auth foundation):
- Installed laravel/sanctum; HasApiTokens on User; published config + migration.
- routes/api.php with /api/v1; Sanctum 'ability' middleware alias registered.
- AuthController: POST login (long-lived revocable device token w/ ability
  mobile-sync + devices table), GET me, POST logout. New Device model/table.

Milestone 2 (vertical slice, offline-first):
- progress_updates: +uuid (client-generated) +client_updated_at.
- ProjectApiController: GET projects (accessibleBy), GET projects/{id}/bundle
  (project/phases/layers/features, membership-authorized).
- SyncController: POST sync — batch ops, idempotent by uuid, per-op result
  (applied/duplicate/error), server-set user_id, authz by permission+membership.
  Currently handles progress_update.create.

Tests: tests/Feature/Api/MobileApiTest (9 passing) — auth, accessible projects,
bundle authz, sync apply+idempotency, permission enforcement.

Also fixed a latent schema bug: projects.reference (and external_reference_1)
existed in the live DB but had no migration — added a guarded migration so fresh
installs match production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 09:05:20 +02:00
parent ba363e7e18
commit 17a824f925
16 changed files with 794 additions and 8 deletions
@@ -0,0 +1,33 @@
<?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('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('devices', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name'); // device_name del login
$table->unsignedBigInteger('token_id')->nullable(); // id del personal_access_token actual
$table->string('app_version')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('devices');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('progress_updates', function (Blueprint $table) {
if (! Schema::hasColumn('progress_updates', 'uuid')) {
$table->uuid('uuid')->nullable()->unique()->after('id');
}
if (! Schema::hasColumn('progress_updates', 'client_updated_at')) {
$table->timestamp('client_updated_at')->nullable()->after('location');
}
});
}
public function down(): void
{
Schema::table('progress_updates', function (Blueprint $table) {
foreach (['uuid', 'client_updated_at'] as $col) {
if (Schema::hasColumn('progress_updates', $col)) {
$table->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* `reference` (and `external_reference_1`) exist in the live DB but were never
* created by a migration (the "add_reference_and_country" migration only added
* `country`). This guarded migration reconciles the schema: on the live DB the
* columns already exist and are skipped; on a fresh install they get created.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
if (! Schema::hasColumn('projects', 'reference')) {
$table->string('reference')->nullable()->after('id');
}
if (! Schema::hasColumn('projects', 'external_reference_1')) {
$table->string('external_reference_1')->nullable()->after('reference');
}
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
foreach (['reference', 'external_reference_1'] as $col) {
if (Schema::hasColumn('projects', $col)) {
$table->dropColumn($col);
}
}
});
}
};