25-02-2025
This commit is contained in:
parent
98084de7d0
commit
70a7776da5
53 changed files with 6719 additions and 833 deletions
199
backend/app/Http/Controllers/Api/EventController.php
Normal file
199
backend/app/Http/Controllers/Api/EventController.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEventRequest;
|
||||
use App\Http\Requests\UpdateEventRequest;
|
||||
use App\Http\Resources\EventResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/events
|
||||
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$query = $request->user()->events()->orderBy('date');
|
||||
|
||||
// Delta sync: only events updated since a given timestamp
|
||||
if ($request->has('since')) {
|
||||
$since = $request->date('since');
|
||||
$query->where('updated_at', '>', $since);
|
||||
}
|
||||
|
||||
// Cursor-based pagination (default 50, max 200)
|
||||
$limit = min((int) $request->input('limit', 50), 200);
|
||||
|
||||
return EventResource::collection(
|
||||
$query->cursorPaginate($limit)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/events
|
||||
*/
|
||||
public function store(StoreEventRequest $request): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()->create([
|
||||
'client_id' => $request->validated('id'),
|
||||
'title' => $request->validated('title'),
|
||||
'date' => $request->validated('date'),
|
||||
'emotion' => $request->validated('emotion'),
|
||||
'custom_color' => $request->validated('customColor'),
|
||||
'gradient_preset' => $request->validated('gradientPreset'),
|
||||
'image' => $request->validated('image'),
|
||||
'note' => $request->validated('note'),
|
||||
]);
|
||||
|
||||
return (new EventResource($event))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/{clientId}
|
||||
*/
|
||||
public function show(Request $request, string $clientId): EventResource
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
return new EventResource($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/events/{clientId}
|
||||
*/
|
||||
public function update(UpdateEventRequest $request, string $clientId): EventResource
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$data = [];
|
||||
$validated = $request->validated();
|
||||
|
||||
if (isset($validated['title'])) {
|
||||
$data['title'] = $validated['title'];
|
||||
}
|
||||
if (isset($validated['date'])) {
|
||||
$data['date'] = $validated['date'];
|
||||
}
|
||||
if (isset($validated['emotion'])) {
|
||||
$data['emotion'] = $validated['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $validated)) {
|
||||
$data['custom_color'] = $validated['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $validated)) {
|
||||
$data['gradient_preset'] = $validated['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $validated)) {
|
||||
$data['image'] = $validated['image'];
|
||||
}
|
||||
if (array_key_exists('note', $validated)) {
|
||||
$data['note'] = $validated['note'];
|
||||
}
|
||||
|
||||
$event->update($data);
|
||||
|
||||
return new EventResource($event->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/events/{clientId}
|
||||
*/
|
||||
public function destroy(Request $request, string $clientId): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$event->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/events/sync
|
||||
* Batch sync: process multiple mutations in one request.
|
||||
*/
|
||||
public function sync(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'mutations' => ['required', 'array', 'max:100'],
|
||||
'mutations.*.action' => ['required', 'in:create,update,delete'],
|
||||
'mutations.*.eventId' => ['required', 'uuid'],
|
||||
'mutations.*.payload' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$results = [];
|
||||
|
||||
foreach ($request->input('mutations') as $mutation) {
|
||||
$action = $mutation['action'];
|
||||
$clientId = $mutation['eventId'];
|
||||
$payload = $mutation['payload'] ?? [];
|
||||
|
||||
try {
|
||||
if ($action === 'create') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if (! $event) {
|
||||
$event = $user->events()->create([
|
||||
'client_id' => $clientId,
|
||||
'title' => $payload['title'] ?? 'Untitled',
|
||||
'date' => $payload['date'] ?? now()->format('Y-m-d'),
|
||||
'emotion' => $payload['emotion'] ?? 0,
|
||||
'custom_color' => $payload['customColor'] ?? null,
|
||||
'gradient_preset' => $payload['gradientPreset'] ?? null,
|
||||
'image' => $payload['image'] ?? null,
|
||||
'note' => $payload['note'] ?? null,
|
||||
]);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'update') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if ($event) {
|
||||
$data = [];
|
||||
if (isset($payload['title'])) {
|
||||
$data['title'] = $payload['title'];
|
||||
}
|
||||
if (isset($payload['date'])) {
|
||||
$data['date'] = $payload['date'];
|
||||
}
|
||||
if (isset($payload['emotion'])) {
|
||||
$data['emotion'] = $payload['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $payload)) {
|
||||
$data['custom_color'] = $payload['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $payload)) {
|
||||
$data['gradient_preset'] = $payload['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$data['image'] = $payload['image'];
|
||||
}
|
||||
if (array_key_exists('note', $payload)) {
|
||||
$data['note'] = $payload['note'];
|
||||
}
|
||||
$event->update($data);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'delete') {
|
||||
$user->events()->where('client_id', $clientId)->delete();
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
}
|
||||
27
backend/app/Http/Requests/StoreEventRequest.php
Normal file
27
backend/app/Http/Requests/StoreEventRequest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'uuid', 'unique:events,client_id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'date' => ['required', 'date_format:Y-m-d'],
|
||||
'emotion' => ['required', 'numeric', 'min:-1', 'max:1'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal file
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'date' => ['sometimes', 'required', 'date_format:Y-m-d'],
|
||||
'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Resources/EventResource.php
Normal file
26
backend/app/Http/Resources/EventResource.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->client_id,
|
||||
'title' => $this->title,
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
'emotion' => (float) $this->emotion,
|
||||
'customColor' => $this->custom_color,
|
||||
'gradientPreset' => $this->gradient_preset,
|
||||
'image' => $this->image,
|
||||
'note' => $this->note ?? '',
|
||||
'syncStatus' => 'synced',
|
||||
'createdAt' => $this->created_at->getTimestampMs(),
|
||||
'updatedAt' => $this->updated_at->getTimestampMs(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
backend/app/Models/Event.php
Normal file
38
backend/app/Models/Event.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\EventFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'title',
|
||||
'date',
|
||||
'emotion',
|
||||
'custom_color',
|
||||
'gradient_preset',
|
||||
'image',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date:Y-m-d',
|
||||
'emotion' => 'decimal:3',
|
||||
'gradient_preset' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,16 @@ namespace App\Models;
|
|||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -50,6 +52,11 @@ class User extends Authenticatable
|
|||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/passport": "^13.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/volt": "^1.7.0"
|
||||
|
|
|
|||
1009
backend/composer.lock
generated
1009
backend/composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,10 @@ return [
|
|||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
'api' => [
|
||||
'driver' => 'passport',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
48
backend/config/passport.php
Normal file
48
backend/config/passport.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Passport will use when
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
'middleware' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Keys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passport uses encryption keys while generating secure access tokens for
|
||||
| your application. By default, the keys are stored as local files but
|
||||
| can be set via environment variables when that is more convenient.
|
||||
|
|
||||
*/
|
||||
|
||||
'private_key' => env('PASSPORT_PRIVATE_KEY'),
|
||||
|
||||
'public_key' => env('PASSPORT_PUBLIC_KEY'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Passport's models will utilize your application's default
|
||||
| database connection. If you wish to use a different connection you
|
||||
| may specify the configured name of the database connection here.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('PASSPORT_CONNECTION'),
|
||||
|
||||
];
|
||||
28
backend/database/factories/EventFactory.php
Normal file
28
backend/database/factories/EventFactory.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
|
||||
*/
|
||||
class EventFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'client_id' => Str::uuid()->toString(),
|
||||
'user_id' => User::factory(),
|
||||
'title' => fake()->sentence(3),
|
||||
'date' => fake()->date(),
|
||||
'emotion' => fake()->randomFloat(3, -1, 1),
|
||||
'custom_color' => null,
|
||||
'gradient_preset' => fake()->optional(0.3)->numberBetween(0, 9),
|
||||
'image' => null,
|
||||
'note' => fake()->optional(0.5)->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?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('oauth_auth_codes', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->index();
|
||||
$table->foreignUuid('client_id');
|
||||
$table->text('scopes')->nullable();
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_auth_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?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('oauth_access_tokens', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id');
|
||||
$table->string('name')->nullable();
|
||||
$table->text('scopes')->nullable();
|
||||
$table->boolean('revoked');
|
||||
$table->timestamps();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_access_tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->char('access_token_id', 80)->index();
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_refresh_tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?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('oauth_clients', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->nullableMorphs('owner');
|
||||
$table->string('name');
|
||||
$table->string('secret')->nullable();
|
||||
$table->string('provider')->nullable();
|
||||
$table->text('redirect_uris');
|
||||
$table->text('grant_types');
|
||||
$table->boolean('revoked');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_clients');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?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('oauth_device_codes', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id')->index();
|
||||
$table->char('user_code', 8)->unique();
|
||||
$table->text('scopes');
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('user_approved_at')->nullable();
|
||||
$table->dateTime('last_polled_at')->nullable();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_device_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('client_id')->unique();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('title');
|
||||
$table->date('date');
|
||||
$table->decimal('emotion', 4, 3)->default(0);
|
||||
$table->string('custom_color')->nullable();
|
||||
$table->unsignedTinyInteger('gradient_preset')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->text('note')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'date']);
|
||||
$table->index(['user_id', 'updated_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events');
|
||||
}
|
||||
};
|
||||
14
backend/routes/api.php
Normal file
14
backend/routes/api.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\EventController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('auth:api')->group(function () {
|
||||
Route::get('/user', fn (Request $request) => $request->user());
|
||||
|
||||
Route::apiResource('events', EventController::class)->parameters([
|
||||
'events' => 'clientId',
|
||||
]);
|
||||
Route::post('/events/sync', [EventController::class, 'sync']);
|
||||
});
|
||||
186
backend/tests/Feature/Api/EventTest.php
Normal file
186
backend/tests/Feature/Api/EventTest.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
Passport::actingAs($this->user);
|
||||
});
|
||||
|
||||
test('can list events', function () {
|
||||
Event::factory()->count(3)->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->getJson('/api/events');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
test('list only returns own events', function () {
|
||||
Event::factory()->count(2)->create(['user_id' => $this->user->id]);
|
||||
Event::factory()->count(3)->create(); // other user
|
||||
|
||||
$response = $this->getJson('/api/events');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
test('can filter events by since parameter', function () {
|
||||
Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now()->subDays(5),
|
||||
]);
|
||||
Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString());
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
test('can create an event', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$response = $this->postJson('/api/events', [
|
||||
'id' => $clientId,
|
||||
'title' => 'Mein Event',
|
||||
'date' => '2024-06-15',
|
||||
'emotion' => 0.75,
|
||||
'customColor' => null,
|
||||
'gradientPreset' => 2,
|
||||
'image' => null,
|
||||
'note' => 'Eine Notiz',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.id', $clientId)
|
||||
->assertJsonPath('data.title', 'Mein Event')
|
||||
->assertJsonPath('data.syncStatus', 'synced');
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'client_id' => $clientId,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('create validates required fields', function () {
|
||||
$response = $this->postJson('/api/events', []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['id', 'title', 'date', 'emotion']);
|
||||
});
|
||||
|
||||
test('can show a single event', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->getJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.id', $event->client_id)
|
||||
->assertJsonPath('data.title', $event->title);
|
||||
});
|
||||
|
||||
test('cannot show another users event', function () {
|
||||
$event = Event::factory()->create();
|
||||
|
||||
$response = $this->getJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('can update an event', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->putJson("/api/events/{$event->client_id}", [
|
||||
'title' => 'Updated Title',
|
||||
'emotion' => -0.5,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.title', 'Updated Title');
|
||||
});
|
||||
|
||||
test('can delete an event', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->deleteJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing('events', ['id' => $event->id]);
|
||||
});
|
||||
|
||||
test('cannot delete another users event', function () {
|
||||
$event = Event::factory()->create();
|
||||
|
||||
$response = $this->deleteJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('batch sync creates updates and deletes', function () {
|
||||
$existingEvent = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
$newId = Str::uuid()->toString();
|
||||
$deleteEvent = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->postJson('/api/events/sync', [
|
||||
'mutations' => [
|
||||
[
|
||||
'action' => 'create',
|
||||
'eventId' => $newId,
|
||||
'payload' => [
|
||||
'title' => 'New via sync',
|
||||
'date' => '2025-01-01',
|
||||
'emotion' => 0.3,
|
||||
],
|
||||
],
|
||||
[
|
||||
'action' => 'update',
|
||||
'eventId' => $existingEvent->client_id,
|
||||
'payload' => [
|
||||
'title' => 'Updated via sync',
|
||||
],
|
||||
],
|
||||
[
|
||||
'action' => 'delete',
|
||||
'eventId' => $deleteEvent->client_id,
|
||||
'payload' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(3, 'results');
|
||||
|
||||
$this->assertDatabaseHas('events', ['client_id' => $newId, 'title' => 'New via sync']);
|
||||
$this->assertDatabaseHas('events', ['client_id' => $existingEvent->client_id, 'title' => 'Updated via sync']);
|
||||
$this->assertDatabaseMissing('events', ['client_id' => $deleteEvent->client_id]);
|
||||
});
|
||||
|
||||
test('sync is idempotent for creates', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => ['title' => 'First', 'date' => '2025-01-01', 'emotion' => 0],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => ['title' => 'Duplicate', 'date' => '2025-01-01', 'emotion' => 0],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
expect(Event::where('client_id', $clientId)->count())->toBe(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue