diff --git a/core/app/Channels/WebhookChannel.php b/core/app/Channels/WebhookChannel.php index 94ccf6e..2b8b3e6 100644 --- a/core/app/Channels/WebhookChannel.php +++ b/core/app/Channels/WebhookChannel.php @@ -3,10 +3,10 @@ namespace App\Channels; use App\Enums\Presence; -use App\Http\Controllers\Api\EventsController as Presenter; use App\Models\Delivery; use App\Models\Event; use App\Models\User; +use App\Presenters\EventPresenter; use Illuminate\Support\Facades\Http; use Throwable; @@ -48,7 +48,7 @@ final class WebhookChannel implements ChannelDriver /** @return array */ public static function payload(Event $event, Delivery $delivery): array { - $presented = Presenter::present($event); + $presented = EventPresenter::forClient($event); return [ 'event' => [ diff --git a/core/app/Http/Controllers/Api/EventsController.php b/core/app/Http/Controllers/Api/EventsController.php index 9972159..00719a6 100644 --- a/core/app/Http/Controllers/Api/EventsController.php +++ b/core/app/Http/Controllers/Api/EventsController.php @@ -2,14 +2,13 @@ namespace App\Http\Controllers\Api; -use App\Enums\DueMode; use App\Enums\EventState; use App\Http\Controllers\Controller; use App\Http\Requests\UpsertEventsRequest; use App\Ingest\EventUpserter; use App\Models\Event; use App\Models\Source; -use App\Scheduling\DueResolver; +use App\Presenters\EventPresenter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -30,7 +29,7 @@ class EventsController extends Controller { $event = $this->lookup($request); - return response()->json(self::present($event)); + return response()->json(EventPresenter::forClient($event)); } public function destroy(Request $request): Response @@ -51,30 +50,4 @@ class EventsController extends Controller return $event; } - - /** @return array */ - public static function present(Event $event): array - { - return [ - 'id' => $event->id, - 'source_ref' => $event->source_ref, - 'topic' => $event->topic, - 'state' => $event->state->value, - 'due_at' => self::dueAt($event), - 'fire_at' => $event->fire_on->format('Y-m-d'), - 'after_due' => $event->after_due->value, - 'payload' => $event->payload, - 'done_at' => $event->done_at?->toIso8601String(), - ]; - } - - private static function dueAt(Event $event): string - { - if ($event->due_mode === DueMode::Fixed) { - return $event->due_instant->toIso8601String(); - } - $date = $event->due_date->format('Y-m-d'); - - return $event->due_time === DueResolver::WHOLE_DAY ? $date : $date.'T'.substr($event->due_time, 0, 5); - } } diff --git a/core/app/Http/Controllers/InboxController.php b/core/app/Http/Controllers/InboxController.php index a4e4d1e..05c592f 100644 --- a/core/app/Http/Controllers/InboxController.php +++ b/core/app/Http/Controllers/InboxController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers; use App\Enums\EventState; -use App\Http\Controllers\Api\EventsController as Presenter; use App\Http\Controllers\Me\ChannelsController; use App\Http\Controllers\Me\ProfileController; +use App\Ingest\ManualEvents; use App\Models\Event; use App\Models\User; +use App\Presenters\EventPresenter; use App\Scheduling\DueResolver; use Carbon\CarbonImmutable; use Illuminate\Contracts\View\View; @@ -36,14 +37,12 @@ class InboxController extends Controller $groups[] = ['state' => $state, 'jp' => $jp, 'ru' => $ru, 'events' => $live->where('state', $state)->values()->all()]; } - $archive = $user->events()->with('source') - ->whereIn('state', [EventState::Done->value, EventState::Expired->value]) - ->orderByDesc('updated_at')->limit(100)->get() + $archive = $user->archive()->get() ->map(fn (Event $e) => [ 'title' => $e->payload['title'], 'source' => self::sourceLabel($e->source->name), - 'due' => Presenter::present($e)['due_at'], - 'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'), + 'due' => EventPresenter::dueAt($e), + 'closed_as' => EventPresenter::closedAs($e), ])->values()->all(); $profile = ProfileController::present($user); @@ -74,7 +73,7 @@ class InboxController extends Controller 'done_label' => $e->payload['done_label'] ?? 'Сделано', 'can_remind' => $e->state !== EventState::Scheduled, 'source' => self::sourceLabel($e->source->name), - 'due' => Presenter::present($e)['due_at'], + 'due' => EventPresenter::dueAt($e), 'due_month' => self::MONTHS[(int) $due->day->format('n') - 1], 'due_day' => $due->day->format('d'), 'due_time' => $wholeDay ? null : $due->moment->format('H:i'), @@ -85,7 +84,7 @@ class InboxController extends Controller public static function sourceLabel(string $name): string { - return $name === 'manual' ? 'вручную' : $name; + return $name === ManualEvents::SOURCE ? 'вручную' : $name; } /** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */ diff --git a/core/app/Http/Controllers/Me/EventsController.php b/core/app/Http/Controllers/Me/EventsController.php index 93a1fd9..3f2ca26 100644 --- a/core/app/Http/Controllers/Me/EventsController.php +++ b/core/app/Http/Controllers/Me/EventsController.php @@ -4,21 +4,18 @@ namespace App\Http\Controllers\Me; use App\Delivery\EventActions; use App\Enums\EventState; -use App\Http\Controllers\Api\EventsController as ApiPresenter; use App\Http\Controllers\Controller; -use App\Ingest\EventUpserter; +use App\Ingest\ManualEvents; use App\Models\Event; -use App\Models\Source; use App\Models\User; -use Carbon\CarbonImmutable; +use App\Presenters\EventPresenter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Str; use Illuminate\Validation\Rule; class EventsController extends Controller { - public function __construct(private readonly EventActions $actions, private readonly EventUpserter $upserter) {} + public function __construct(private readonly EventActions $actions, private readonly ManualEvents $manual) {} public function index(Request $request): JsonResponse { @@ -33,7 +30,7 @@ class EventsController extends Controller $events = $user->events()->with('source')->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()]); + return response()->json(['events' => $events->map(EventPresenter::forUser(...))->values()]); } public function ack(Request $request, int $event): JsonResponse @@ -41,7 +38,7 @@ class EventsController extends Controller $model = $this->own($request, $event); $this->actions->ack($model); - return response()->json(ApiPresenter::present($model->fresh())); + return response()->json(EventPresenter::forUser($model->fresh('source'))); } public function done(Request $request, int $event): JsonResponse @@ -49,7 +46,7 @@ class EventsController extends Controller $model = $this->own($request, $event); $this->actions->done($model); - return response()->json(ApiPresenter::present($model->fresh())); + return response()->json(EventPresenter::forUser($model->fresh('source'))); } public function store(Request $request): JsonResponse @@ -63,39 +60,22 @@ class EventsController extends Controller 'due_time' => ['sometimes', 'nullable', 'date_format:H:i'], ]); - $source = Source::firstOrCreate(['name' => 'manual'], ['token_hash' => hash('sha256', Str::random(64))]); - $dueAt = $data['due_date'].(empty($data['due_time']) ? '' : 'T'.$data['due_time']); + $event = $this->manual->create($user, [ + 'title' => $data['title'], + 'subtitle' => $data['subtitle'] ?? null, + 'due_at' => $data['due_date'].(empty($data['due_time']) ? '' : 'T'.$data['due_time']), + ]); - [$result] = $this->upserter->upsert($source, [[ - 'user' => $user->login, - 'source_ref' => 'manual:'.Str::ulid(), - 'topic' => '', - 'due_at' => $dueAt, - 'fire_at' => CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'), - 'after_due' => 'keep', - 'payload' => array_filter([ - 'title' => $data['title'], - 'subtitle' => $data['subtitle'] ?? null, - 'done_label' => 'Сделано', - ], fn ($v) => $v !== null && $v !== ''), - ]]); - - $event = Event::with('source')->findOrFail($result['id']); - - return response()->json(ApiPresenter::present($event) + ['source' => $event->source->name], 201); + return response()->json(EventPresenter::forUser($event), 201); } public function archive(Request $request): JsonResponse { /** @var User $user */ $user = $request->attributes->get('user'); - $events = $user->events()->with('source') - ->whereIn('state', [EventState::Done->value, EventState::Expired->value]) - ->orderByDesc('updated_at')->limit(100)->get(); - return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + [ - 'source' => $e->source->name, - 'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'), + return response()->json(['events' => $user->archive()->get()->map(fn (Event $e) => EventPresenter::forUser($e) + [ + 'closed_as' => EventPresenter::closedAs($e), ])->values()]); } @@ -104,6 +84,6 @@ class EventsController extends Controller /** @var User $user */ $user = $request->attributes->get('user'); - return $user->events()->whereKey($id)->firstOrFail(); + return $user->ownEvent($id); } } diff --git a/core/app/Ingest/ManualEvents.php b/core/app/Ingest/ManualEvents.php new file mode 100644 index 0000000..018e405 --- /dev/null +++ b/core/app/Ingest/ManualEvents.php @@ -0,0 +1,68 @@ +source->name === self::SOURCE; + } + + /** + * @param array{title:string, due_at:string, subtitle?:?string, fire_at?:?string, after_due?:?string, done_label?:?string, deep_link?:?string} $fields + */ + public function create(User $user, array $fields): Event + { + return $this->upsert($user, 'manual:'.Str::ulid(), $fields); + } + + private function upsert(User $user, string $sourceRef, array $fields): Event + { + [$result] = $this->upserter->upsert($this->source(), [[ + 'user' => $user->login, + 'source_ref' => $sourceRef, + 'topic' => '', + 'due_at' => $fields['due_at'], + 'fire_at' => $fields['fire_at'] ?? self::defaultFireAt($fields['due_at']), + 'after_due' => $fields['after_due'] ?? 'keep', + 'payload' => array_filter([ + 'title' => $fields['title'], + 'subtitle' => $fields['subtitle'] ?? null, + 'done_label' => $fields['done_label'] ?? 'Сделано', + 'deep_link' => $fields['deep_link'] ?? null, + ], fn ($v) => $v !== null && $v !== ''), + ]]); + + return Event::with('source')->findOrFail($result['id']); + } + + private function source(): Source + { + return Source::firstOrCreate(['name' => self::SOURCE], ['token_hash' => hash('sha256', Str::random(64))]); + } + + /** День срока минус DEFAULT_LEAD_DAYS; из due_at берётся только дата. */ + private static function defaultFireAt(string $dueAt): string + { + return CarbonImmutable::createFromFormat('Y-m-d', substr($dueAt, 0, 10))->subDays(self::DEFAULT_LEAD_DAYS)->format('Y-m-d'); + } +} diff --git a/core/app/Models/User.php b/core/app/Models/User.php index bde0c2c..7c5a08a 100644 --- a/core/app/Models/User.php +++ b/core/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\EventState; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -20,6 +21,20 @@ class User extends Model return $this->hasMany(Event::class); } + /** Событие пользователя по id; чужой id — «не найдено», а не 403. */ + public function ownEvent(int $id): Event + { + return $this->events()->with('source')->whereKey($id)->firstOrFail(); + } + + /** Закрытые события для архива: done и expired, свежие первыми, не больше 100. */ + public function archive(): HasMany + { + return $this->events()->with('source') + ->whereIn('state', [EventState::Done->value, EventState::Expired->value]) + ->orderByDesc('updated_at')->limit(100); + } + /** «Сейчас» в поясе пользователя. */ public function now(): CarbonImmutable { diff --git a/core/app/Presenters/EventPresenter.php b/core/app/Presenters/EventPresenter.php new file mode 100644 index 0000000..6b5fd18 --- /dev/null +++ b/core/app/Presenters/EventPresenter.php @@ -0,0 +1,59 @@ + */ + public static function forClient(Event $event): array + { + return [ + 'id' => $event->id, + 'source_ref' => $event->source_ref, + 'topic' => $event->topic, + 'state' => $event->state->value, + 'due_at' => self::dueAt($event), + 'fire_at' => $event->fire_on->format('Y-m-d'), + 'after_due' => $event->after_due->value, + 'payload' => $event->payload, + 'done_at' => $event->done_at?->toIso8601String(), + ]; + } + + /** + * Пользователю (и агенту) поверх клиентского: чей источник и можно ли править. + * `editable` — явный флаг, а не «сравни source со строкой». + * + * @return array + */ + public static function forUser(Event $event): array + { + return self::forClient($event) + [ + 'source' => $event->source->name, + 'editable' => ManualEvents::owns($event), + ]; + } + + /** Подпись закрытого события в архиве. */ + public static function closedAs(Event $event): string + { + return $event->state === EventState::Expired ? 'Истекло' : ($event->payload['done_label'] ?? 'Сделано'); + } + + public static function dueAt(Event $event): string + { + if ($event->due_mode === DueMode::Fixed) { + return $event->due_instant->toIso8601String(); + } + $date = $event->due_date->format('Y-m-d'); + + return $event->due_time === DueResolver::WHOLE_DAY ? $date : $date.'T'.substr($event->due_time, 0, 5); + } +} diff --git a/core/composer.json b/core/composer.json index 9b8fbd5..f29c0de 100644 --- a/core/composer.json +++ b/core/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "laravel/framework": "^13.17", + "laravel/mcp": "^0.9.4", "laravel/tinker": "^3.0" }, "require-dev": { diff --git a/core/composer.lock b/core/composer.lock index 84c83bf..7a8181c 100644 --- a/core/composer.lock +++ b/core/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cda2add6397422fb1a2eb5b9207f1575", + "content-hash": "b488c4ce2e7acab358ca9a7e236b08e2", "packages": [ { "name": "brick/math", @@ -1281,6 +1281,80 @@ }, "time": "2026-09-01T21:42:30+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.9.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/7ca5b923630118696602d14348cd0466a5e853ec", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-08-13T15:01:07+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.24", diff --git a/core/tests/Feature/EventPresentationTest.php b/core/tests/Feature/EventPresentationTest.php new file mode 100644 index 0000000..755aad6 --- /dev/null +++ b/core/tests/Feature/EventPresentationTest.php @@ -0,0 +1,53 @@ + 'nikita']; + + protected function setUp(): void + { + parent::setUp(); + config(['hado.default_tz' => 'UTC']); + CarbonImmutable::setTestNow('2026-09-05T12:00:00Z'); + } + + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + parent::tearDown(); + } + + public function test_user_facing_events_say_whether_they_are_editable(): void + { + $this->getJson('/me', $this->as); + $user = User::sole(); + $docs = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + Event::create([ + 'source_id' => $docs->id, 'user_id' => $user->id, 'source_ref' => 'person:1', 'topic' => 'bd', + 'due_mode' => DueMode::Local, 'due_date' => '2026-09-05', 'due_time' => '24:00:00', 'fire_on' => '2026-09-05', + 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'ДР'], 'state' => EventState::Today, + 'quiet_until' => CarbonImmutable::now()->subDay(), + ]); + + $this->postJson('/me/events', ['title' => 'Своё', 'due_date' => '2026-10-01'], $this->as) + ->assertCreated()->assertJsonPath('editable', true); + + $bySource = collect($this->getJson('/me/events', $this->as)->assertOk()->json('events'))->keyBy('source'); + $this->assertFalse($bySource['docs']['editable']); + $this->assertTrue($bySource['manual']['editable']); + } +}