From 1b03fe07968b44b960b622234200171c94ce2a09 Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Fri, 4 Sep 2026 07:05:50 -0300 Subject: [PATCH] feat: remote user, ack/done actions and /me API Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- core/app/Delivery/EventActions.php | 39 +++++ .../Http/Controllers/Me/EventsController.php | 54 +++++++ .../Http/Controllers/Me/ProfileController.php | 70 +++++++++ core/app/Http/Middleware/RemoteUser.php | 14 ++ core/routes/web.php | 11 +- core/tests/Feature/MeApiTest.php | 134 ++++++++++++++++++ 6 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 core/app/Delivery/EventActions.php create mode 100644 core/app/Http/Controllers/Me/EventsController.php create mode 100644 core/app/Http/Controllers/Me/ProfileController.php create mode 100644 core/tests/Feature/MeApiTest.php diff --git a/core/app/Delivery/EventActions.php b/core/app/Delivery/EventActions.php new file mode 100644 index 0000000..167ae33 --- /dev/null +++ b/core/app/Delivery/EventActions.php @@ -0,0 +1,39 @@ +isTerminal()) { + return; + } + $user = $event->user; + $now = $user->now(); + $event->quiet_until = $this->quiet->afterAck($event, $user, $event->state, $this->due->resolve($event, $user), $now); + $event->save(); + } + + /** «Сделано»: закрыть событие целиком. */ + public function done(Event $event): void + { + if ($event->isTerminal()) { + return; + } + $event->state = EventState::Done; + $event->done_at = now(); + $event->save(); + } +} diff --git a/core/app/Http/Controllers/Me/EventsController.php b/core/app/Http/Controllers/Me/EventsController.php new file mode 100644 index 0000000..29b483e --- /dev/null +++ b/core/app/Http/Controllers/Me/EventsController.php @@ -0,0 +1,54 @@ +attributes->get('user'); + $states = $request->query('state') + ? [$request->query('state')] + : EventState::nonTerminalValues(); + + $events = $user->events()->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get(); + + return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]); + } + + public function ack(Request $request, int $event): JsonResponse + { + $model = $this->own($request, $event); + $this->actions->ack($model); + + return response()->json(ApiPresenter::present($model->fresh())); + } + + public function done(Request $request, int $event): JsonResponse + { + $model = $this->own($request, $event); + $this->actions->done($model); + + return response()->json(ApiPresenter::present($model->fresh())); + } + + private function own(Request $request, int $id): Event + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + return $user->events()->whereKey($id)->firstOrFail(); + } +} diff --git a/core/app/Http/Controllers/Me/ProfileController.php b/core/app/Http/Controllers/Me/ProfileController.php new file mode 100644 index 0000000..87dc471 --- /dev/null +++ b/core/app/Http/Controllers/Me/ProfileController.php @@ -0,0 +1,70 @@ +json(self::present($request->attributes->get('user'))); + } + + public function update(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + $data = $request->validate([ + 'tz' => ['sometimes', 'string', Rule::in(\DateTimeZone::listIdentifiers())], + 'quiet_start' => ['sometimes', 'date_format:H:i'], + 'quiet_end' => ['sometimes', 'date_format:H:i'], + ]); + + $start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5); + $end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5); + if ($end >= $start) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start', + ]); + } + + $user->fill([ + 'tz' => $data['tz'] ?? $user->tz, + 'quiet_start' => $start.':00', + 'quiet_end' => $end.':00', + ])->save(); + + return response()->json(self::present($user)); + } + + public function heartbeat(Request $request): Response + { + /** @var User $user */ + $user = $request->attributes->get('user'); + Cache::put("heartbeat:{$user->id}", true, config('hado.web_heartbeat_ttl')); + + return response()->noContent(); + } + + /** @return array */ + public static function present(User $user): array + { + return [ + 'login' => $user->login, + 'tz' => $user->tz, + 'quiet_start' => substr($user->quiet_start, 0, 5), + 'quiet_end' => substr($user->quiet_end, 0, 5), + 'badge' => $user->events()->whereIn('state', [EventState::Today->value, EventState::Overdue->value])->count(), + ]; + } +} diff --git a/core/app/Http/Middleware/RemoteUser.php b/core/app/Http/Middleware/RemoteUser.php index d9d0595..98e3f82 100644 --- a/core/app/Http/Middleware/RemoteUser.php +++ b/core/app/Http/Middleware/RemoteUser.php @@ -2,14 +2,28 @@ namespace App\Http\Middleware; +use App\Ingest\EventUpserter; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; +/** + * Доверяет X-Remote-User от Caddy forward_auth (сеть изолирована, как в sekai). + * На публичных маршрутах (/api, /a, /hooks) этот middleware не стоит. + */ class RemoteUser { + public function __construct(private readonly EventUpserter $users) {} + public function handle(Request $request, Closure $next): Response { + $login = trim((string) $request->header('X-Remote-User', '')); + if ($login === '') { + return response()->json(['message' => 'Нет X-Remote-User: запрос должен идти через хаб'], 401); + } + + $request->attributes->set('user', $this->users->ensureUser($login)); + return $next($request); } } diff --git a/core/routes/web.php b/core/routes/web.php index 86a06c5..1818baf 100644 --- a/core/routes/web.php +++ b/core/routes/web.php @@ -1,7 +1,14 @@ group(function () { + Route::get('/me', [ProfileController::class, 'show']); + Route::patch('/me', [ProfileController::class, 'update']); + Route::post('/me/heartbeat', [ProfileController::class, 'heartbeat']); + Route::get('/me/events', [MeEvents::class, 'index']); + Route::post('/me/events/{event}/ack', [MeEvents::class, 'ack']); + Route::post('/me/events/{event}/done', [MeEvents::class, 'done']); }); diff --git a/core/tests/Feature/MeApiTest.php b/core/tests/Feature/MeApiTest.php new file mode 100644 index 0000000..a49f7ff --- /dev/null +++ b/core/tests/Feature/MeApiTest.php @@ -0,0 +1,134 @@ + 'UTC']); + CarbonImmutable::setTestNow('2026-09-30T11:17:00Z'); + $this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + } + + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + parent::tearDown(); + } + + private function as(string $login = 'nikita'): array + { + return ['X-Remote-User' => $login]; + } + + private function event(User $user, EventState $state, string $topic = 't', string $due = '2026-09-30', string $fireOn = '2026-09-30'): Event + { + return Event::create([ + 'source_id' => $this->source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic, + 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => '24:00:00', 'fire_on' => $fireOn, + 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => $state, + 'quiet_until' => CarbonImmutable::now()->subDay(), + ]); + } + + public function test_requires_remote_user_header(): void + { + $this->getJson('/me')->assertStatus(401); + } + + public function test_first_visit_creates_user_with_web_channel(): void + { + $this->getJson('/me', $this->as())->assertOk() + ->assertJsonPath('login', 'nikita') + ->assertJsonPath('tz', 'UTC') + ->assertJsonPath('quiet_start', '22:00') + ->assertJsonPath('quiet_end', '09:00') + ->assertJsonPath('badge', 0); + + $this->assertSame(ChannelType::Web, User::sole()->channels()->sole()->type); + } + + public function test_badge_counts_today_and_overdue_only(): void + { + $this->getJson('/me', $this->as()); + $user = User::sole(); + $this->event($user, EventState::Today, 'a'); + $this->event($user, EventState::Overdue, 'b'); + $this->event($user, EventState::Preparing, 'c'); + $this->event($user, EventState::Done, 'd'); + + $this->getJson('/me', $this->as())->assertJsonPath('badge', 2); + } + + public function test_patch_profile_validates_timezone_and_quiet_hours(): void + { + $this->patchJson('/me', ['tz' => 'Asia/Shanghai', 'quiet_start' => '23:00', 'quiet_end' => '08:00'], $this->as()) + ->assertOk()->assertJsonPath('tz', 'Asia/Shanghai')->assertJsonPath('quiet_start', '23:00'); + + $this->patchJson('/me', ['tz' => 'Mars/Olympus'], $this->as())->assertStatus(422); + $this->patchJson('/me', ['quiet_start' => '08:00', 'quiet_end' => '22:00'], $this->as()) + ->assertStatus(422)->assertJsonValidationErrors(['quiet_end']); + } + + public function test_lists_own_non_terminal_events(): void + { + $this->getJson('/me', $this->as()); + $this->getJson('/me', $this->as('other')); + $me = User::where('login', 'nikita')->sole(); + $other = User::where('login', 'other')->sole(); + $this->event($me, EventState::Today, 'a'); + $this->event($me, EventState::Done, 'b'); + $this->event($other, EventState::Today, 'c'); + + $this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a'); + $this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b'); + } + + public function test_ack_sets_quiet_until_and_done_closes(): void + { + $this->getJson('/me', $this->as()); + $user = User::sole(); + $today = $this->event($user, EventState::Today, 'a'); + $done = $this->event($user, EventState::Preparing, 'b', '2026-10-30', '2026-09-30'); + + $this->postJson("/me/events/{$today->id}/ack", [], $this->as())->assertOk()->assertJsonPath('state', 'today'); + $this->assertSame('2026-09-30T13:00:00+00:00', $today->fresh()->quiet_until->toIso8601String()); + + $this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk()->assertJsonPath('state', 'done'); + $this->assertNotNull($done->fresh()->done_at); + + $this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk(); // идемпотентно + } + + public function test_cannot_touch_someone_elses_event(): void + { + $this->getJson('/me', $this->as('other')); + $event = $this->event(User::sole(), EventState::Today); + + $this->postJson("/me/events/{$event->id}/done", [], $this->as('nikita'))->assertNotFound(); + } + + public function test_heartbeat_marks_web_presence(): void + { + $this->postJson('/me/heartbeat', [], $this->as())->assertNoContent(); + $this->assertTrue(Cache::has('heartbeat:'.User::sole()->id)); + } +}