From 070cda42f8f79ee7fabfa19217e1aac66d3aca5e Mon Sep 17 00:00:00 2001 From: Tonko Mulder Date: Tue, 1 Sep 2026 16:47:41 +0200 Subject: [PATCH 1/4] Restructure v1 spec and record status flow / companies index decisions - v1.md: add TOC, explicit status transition table, draft screens checklist, and a validation placeholder section - decisions.md: record that status transitions are forward-only/ terminal-final (reapplying creates a new Application record), transition legality lives on ApplicationStatus::canTransitionTo(), and Companies get a standalone index screen --- .ai/decisions.md | 37 +++++++++++ .ai/features/v1.md | 150 +++++++++++++++++++++++++++++++++------------ 2 files changed, 148 insertions(+), 39 deletions(-) diff --git a/.ai/decisions.md b/.ai/decisions.md index 384a125..eeb7085 100644 --- a/.ai/decisions.md +++ b/.ai/decisions.md @@ -11,3 +11,40 @@ a council session, as long as the rationale is worth keeping. See that's where the trade-off lives; this is where the outcome does. --- + +## Status transitions: forward-only, terminal-final; reapplying creates a new `Application` +_Recorded: 2026-09-01_ + +The v1 spec listed the status flow (`lead` → `applied` → `interviewing` → +terminal: `offer` | `rejected` | `withdrawn`) but not whether a status could +move backward, nor where the legality of a transition gets checked. +`ApplicationStatus::statusChange()` was mid-implementation and didn't +handle the branch at `interviewing` at all. + +**Why:** no backward transitions — a terminal status is final. If a +rejected/withdrawn application turns into a new opportunity (reapplying, or +the company reaching out again), that's a **new `Application` record**, not +a revived status on the old one. Keeps the history honest: one record, one +linear-then-terminal path, no ambiguity about "was this reopened or is this +a fresh attempt". + +Transition legality is validated on the enum itself — +`ApplicationStatus::canTransitionTo(self $target): bool` — rather than on +the `Application` model or a form request. Keeps the allowed-transitions +table next to the enum it describes; the Observer still owns turning an +accepted write into an `Interaction`, it doesn't decide legality. + +See `.ai/features/v1.md#status-flow`. + +--- + +## Companies get a standalone index screen +_Recorded: 2026-09-01_ + +The original spec didn't say whether a company was reachable only via an +application, or had its own list view. + +**Why:** v1 gets a standalone Companies index. See +`.ai/features/v1.md#screens-v1`. + +--- diff --git a/.ai/features/v1.md b/.ai/features/v1.md index abd8b7c..abaf2d4 100644 --- a/.ai/features/v1.md +++ b/.ai/features/v1.md @@ -2,54 +2,66 @@ The core of the app: track job applications with a timeline of interactions and status changes. +## Table of contents + +- [Data model](#data-model) +- [Enums](#enums) +- [Status flow](#status-flow) +- [Behaviour](#behaviour) +- [Enrichment](#enrichment) +- [Screens (v1)](#screens-v1) +- [Validation](#validation) +- [Out of scope](#out-of-scope-explicit) +- [Definition of done](#definition-of-done-v1) + ## Data model ### Company -| field | type | note | -|---|---|---| -| id | pk | | -| name | string, required | lookup key; manual entry must always be possible | -| kvk_number | string, nullable | only filled after enrichment | -| website | string, nullable | | -| city | string, nullable | | -| sbi_code | string, nullable | from KVK | -| sbi_description | string, nullable | from KVK | -| enriched_at | timestamp, nullable | drives caching logic | -| timestamps | | | +| field | type | note | +|-----------------|---------------------|--------------------------------------------------| +| id | pk | | +| name | string, required | lookup key; manual entry must always be possible | +| kvk_number | string, nullable | only filled after enrichment | +| website | string, nullable | | +| city | string, nullable | | +| sbi_code | string, nullable | from KVK | +| sbi_description | string, nullable | from KVK | +| enriched_at | timestamp, nullable | drives caching logic | +| timestamps | | | ### Application (core) -| field | type | note | -|---|---|---| -| id | pk | | -| company_id | fk | | -| role_title | string, required | e.g. "Medior Laravel Developer" | -| source | string, nullable | recruiter / direct / PHPGroningen | -| status | enum `ApplicationStatus` | current truth (fast to query) | -| applied_at | date, nullable | | -| notes | text, nullable | | -| timestamps | | | +| field | type | note | +|------------|--------------------------|-----------------------------------| +| id | pk | | +| company_id | fk | | +| role_title | string, required | e.g. "Medior Laravel Developer" | +| source | string, nullable | recruiter / direct / PHPGroningen | +| status | enum `ApplicationStatus` | current truth (fast to query) | +| applied_at | date, nullable | | +| notes | text, nullable | | +| timestamps | | | ### Contact -| field | type | note | -|---|---|---| -| id | pk | | -| company_id | fk | | -| name | string, required | | -| role | string, nullable | recruiter / hiring manager | -| email | string, nullable | | -| phone | string, nullable | | -| timestamps | | | +| field | type | note | +|------------|------------------|----------------------------| +| id | pk | | +| company_id | fk | | +| name | string, required | | +| role | string, nullable | recruiter / hiring manager | +| email | string, nullable | | +| phone | string, nullable | | +| timestamps | | | ### Interaction (timeline) -| field | type | note | -|---|---|---| -| id | pk | | -| application_id | fk | | -| contact_id | fk, nullable | not every moment has a person | -| type | enum `InteractionType` | | -| occurred_at | datetime | when it happened (≠ created_at) | -| body | text, nullable | | -| timestamps | | created_at = when entered | +| field | type | note | +|----------------|------------------------|---------------------------------| +| id | pk | | +| application_id | fk | | +| contact_id | fk, nullable | not every moment has a person | +| type | enum `InteractionType` | | +| occurred_at | datetime | when it happened (≠ created_at) | +| body | text, nullable | | +| timestamps | | created_at = when entered | Deliberate split of `occurred_at` vs `created_at`: a conversation from yesterday can be logged today. @@ -61,6 +73,34 @@ Deliberate split of `occurred_at` vs `created_at`: a conversation from yesterday ### InteractionType (backed enum) `note`, `status_change`, `email`, `call`, `interview` +## Status flow + +The three linear steps (`lead` → `applied` → `interviewing`) each have exactly one +next status. `interviewing` is a branch point: it can move to any one of the three +terminal statuses, not a single "next". + +| from | allowed to | +|----------------|----------------------------------| +| `lead` | `applied` | +| `applied` | `interviewing` | +| `interviewing` | `offer`, `rejected`, `withdrawn` | +| `offer` | — (terminal) | +| `rejected` | — (terminal) | +| `withdrawn` | — (terminal) | + +**Decided:** +- No backward transitions, ever. Terminal is final — a rejection that turns + into a new opportunity (e.g. reapplying, or the company reaching out + again) is a **new `Application` record**, not a status revert on the old + one. `ApplicationStatus::statusChange()` needs to be replaced/extended: + it currently implies forward-only for the first two steps but doesn't + handle the `interviewing` branch at all. +- Transition legality is validated on the enum itself: + `ApplicationStatus::canTransitionTo(self $target): bool`, checked against + the table above. The Observer still owns turning an accepted status write + into an `Interaction` — `canTransitionTo()` only decides whether the write + is allowed in the first place. + ## Behaviour - **A status change automatically generates an `Interaction`** of type `status_change`, via an observer on `Application`. The observer keeps `status` (current truth) and the interaction history in sync. This is the only place that may happen — no duplicate write paths. @@ -78,6 +118,38 @@ The free KVK Open Dataset (HVDS) is unsuitable: it contains no company name/KVK Caching/error handling: `enriched_at` determines whether to re-query; on an empty/failed response the existing data stays and is not overwritten. This rule must have a Pest test, not just a spec line. +## Screens (v1) + +Draft list of screens implied by "CRUD on applications with the status flow" +(definition of done). Not yet a frozen contract — routes, controller +signatures and Blade component props are the human's to define; this is a +starting checklist so the agent knows what markup to eventually expect. + +| screen | purpose | +|----------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| Applications index | List applications, current status visible per row, filter/sort TBD | +| Application show | Full detail: company, contact(s), status, interaction timeline | +| Application create | New application; company lookup-or-create inline (`name` is the only required company field) | +| Application edit | Edit `role_title`, `source`, `applied_at`, `notes` | +| Status change action | Triggered from the show screen; must only offer the statuses valid from the current one (see [Status flow](#status-flow)) | +| Interaction create | Log a note/email/call/interview against an application, optionally linked to a contact, with an `occurred_at` that can be backdated | +| Companies index | Standalone list of companies | +| Company show | Company detail + its applications + its contacts | +| Contact create/edit | Nested under a company | + +## Validation + +Not yet specified per field. Placeholder so this isn't silently skipped — +fill in once the form requests are written: + +- `Company.name` — required, no other constraint stated (manual entry must + always be possible, per the data model note) +- `Application.role_title` — required +- `Application.company_id` — required, must exist +- `Interaction.type` — required, must be a valid `InteractionType` +- `Interaction.occurred_at` — required; can it be in the future, or must it + be `<= now()`? Not decided. + ## Out of scope (explicit) - Auth / multi-user (no `user_id` on models — do not carry a dead column) From a627adc4004a520d37c03fc9db7610ad664a2076 Mon Sep 17 00:00:00 2001 From: Tonko Mulder Date: Wed, 2 Sep 2026 16:29:49 +0200 Subject: [PATCH 2/4] Allow applied -> rejected/withdrawn without an interview Record the decision and update the v1 status flow table: a rejection or withdrawal doesn't have to pass through interviewing, matching what ApplicationStatus::canTransitionTo() now implements. offer stays reachable only from interviewing. --- .ai/decisions.md | 17 +++++++++++++++++ .ai/features/v1.md | 24 ++++++++++++------------ tests/Unit/ApplicationStatusTest.php | 10 ++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/ApplicationStatusTest.php diff --git a/.ai/decisions.md b/.ai/decisions.md index eeb7085..434af7f 100644 --- a/.ai/decisions.md +++ b/.ai/decisions.md @@ -38,6 +38,23 @@ See `.ai/features/v1.md#status-flow`. --- +## `applied` can also go straight to `rejected`/`withdrawn`, without an interview +_Recorded: 2026-09-02_ + +The status flow originally only let the three terminal statuses branch off +`interviewing`. In practice a rejection or a withdrawal can happen right +after applying, before an interview is ever scheduled — the flow shouldn't +force an application through `interviewing` just to reach a terminal state +it never earned. + +**Why:** `ApplicationStatus::canTransitionTo()` now allows +`applied → interviewing|rejected|withdrawn`, not just `applied → +interviewing`. `offer` is deliberately not part of that set — an offer +without an interview isn't a realistic v1 case, so `applied → offer` stays +disallowed. See `.ai/features/v1.md#status-flow`. + +--- + ## Companies get a standalone index screen _Recorded: 2026-09-01_ diff --git a/.ai/features/v1.md b/.ai/features/v1.md index abaf2d4..1866ee1 100644 --- a/.ai/features/v1.md +++ b/.ai/features/v1.md @@ -75,18 +75,18 @@ Deliberate split of `occurred_at` vs `created_at`: a conversation from yesterday ## Status flow -The three linear steps (`lead` → `applied` → `interviewing`) each have exactly one -next status. `interviewing` is a branch point: it can move to any one of the three -terminal statuses, not a single "next". - -| from | allowed to | -|----------------|----------------------------------| -| `lead` | `applied` | -| `applied` | `interviewing` | -| `interviewing` | `offer`, `rejected`, `withdrawn` | -| `offer` | — (terminal) | -| `rejected` | — (terminal) | -| `withdrawn` | — (terminal) | +`lead` → `applied` has exactly one next status. From `applied` and +`interviewing` onward it's a branch: a rejection or withdrawal can happen +without ever reaching an interview, not only after one. + +| from | allowed to | +|----------------|------------------------------------------------| +| `lead` | `applied` | +| `applied` | `interviewing`, `rejected`, `withdrawn` | +| `interviewing` | `offer`, `rejected`, `withdrawn` | +| `offer` | — (terminal) | +| `rejected` | — (terminal) | +| `withdrawn` | — (terminal) | **Decided:** - No backward transitions, ever. Terminal is final — a rejection that turns diff --git a/tests/Unit/ApplicationStatusTest.php b/tests/Unit/ApplicationStatusTest.php new file mode 100644 index 0000000..897e743 --- /dev/null +++ b/tests/Unit/ApplicationStatusTest.php @@ -0,0 +1,10 @@ +canTransitionTo(ApplicationStatus::REJECTED))->toBeTrue(); + }); +}); From 0699eb3dbe2adbdf2cb7c0e7add0cc5b9df8656e Mon Sep 17 00:00:00 2001 From: Tonko Mulder Date: Wed, 2 Sep 2026 16:34:44 +0200 Subject: [PATCH 3/4] feat: add `statusChange()` Allow for the application status to progress --- app/Enums/ApplicationStatus.php | 26 +++++++++++++++++++++++++ tests/Unit/ApplicationStatusTest.php | 29 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/Enums/ApplicationStatus.php b/app/Enums/ApplicationStatus.php index 2561d14..1071b34 100644 --- a/app/Enums/ApplicationStatus.php +++ b/app/Enums/ApplicationStatus.php @@ -12,4 +12,30 @@ enum ApplicationStatus: string case OFFER = 'offer'; // terminal case REJECTED = 'rejected'; // terminal case WITHDRAWN = 'withdrawn'; // terminal + + private function canTransitionTo(self $target): bool + { + if ($this === $target) { + return false; + } + + if ($this === self::INTERVIEWING) { + return in_array($target, [self::OFFER, self::REJECTED, self::WITHDRAWN], strict: true); + } + + if ($this === self::APPLIED && in_array($target, [self::INTERVIEWING, self::REJECTED, self::WITHDRAWN], strict: true)) { + return true; + } + + return ($this === self::LEAD) && ($target === self::APPLIED); + } + + public function statusChange(self $status): self + { + if (! $this->canTransitionTo($status)) { + return $this; + } + + return $status; + } } diff --git a/tests/Unit/ApplicationStatusTest.php b/tests/Unit/ApplicationStatusTest.php index 897e743..d1a6678 100644 --- a/tests/Unit/ApplicationStatusTest.php +++ b/tests/Unit/ApplicationStatusTest.php @@ -5,6 +5,33 @@ describe('the enum can only move forward', function () { test('an interviewing state can not move backward', function () { $enum = ApplicationStatus::INTERVIEWING; - expect($enum->canTransitionTo(ApplicationStatus::REJECTED))->toBeTrue(); + expect($enum->statusChange(ApplicationStatus::OFFER))->toBe(ApplicationStatus::OFFER) + ->and($enum->statusChange(ApplicationStatus::REJECTED)) + ->toBe(ApplicationStatus::REJECTED) + ->and($enum->statusChange(ApplicationStatus::WITHDRAWN)) + ->toBe(ApplicationStatus::WITHDRAWN) + ->and($enum->statusChange(ApplicationStatus::APPLIED)) + ->toBe(ApplicationStatus::INTERVIEWING) + ->and($enum->statusChange(ApplicationStatus::LEAD)) + ->toBe(ApplicationStatus::INTERVIEWING); + }); + + test('status change must be different than the current', function () { + $enum = ApplicationStatus::LEAD; + expect($enum->statusChange(ApplicationStatus::LEAD))->toBe(ApplicationStatus::LEAD); + }); + + test('should follow the order of the status flow', function () { + $enum = ApplicationStatus::LEAD; + $applied = $enum->statusChange(ApplicationStatus::APPLIED); + expect($applied)->toBe(ApplicationStatus::APPLIED); + $interviewing = $applied->statusChange(ApplicationStatus::INTERVIEWING); + expect($interviewing)->toBe(ApplicationStatus::INTERVIEWING); + }); + + test('terminal state should stay terminal', function () { + $enum = ApplicationStatus::OFFER; + $applied = $enum->statusChange(ApplicationStatus::APPLIED); + expect($applied)->toBe(ApplicationStatus::OFFER); }); }); From c753afc2ff853bff4b2edb55a547bc551aafecd8 Mon Sep 17 00:00:00 2001 From: Tonko Mulder Date: Thu, 3 Sep 2026 10:33:11 +0200 Subject: [PATCH 4/4] style: fix Pint method chaining indentation --- tests/Unit/ApplicationStatusTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/ApplicationStatusTest.php b/tests/Unit/ApplicationStatusTest.php index d1a6678..e84f4c9 100644 --- a/tests/Unit/ApplicationStatusTest.php +++ b/tests/Unit/ApplicationStatusTest.php @@ -7,13 +7,13 @@ $enum = ApplicationStatus::INTERVIEWING; expect($enum->statusChange(ApplicationStatus::OFFER))->toBe(ApplicationStatus::OFFER) ->and($enum->statusChange(ApplicationStatus::REJECTED)) - ->toBe(ApplicationStatus::REJECTED) + ->toBe(ApplicationStatus::REJECTED) ->and($enum->statusChange(ApplicationStatus::WITHDRAWN)) - ->toBe(ApplicationStatus::WITHDRAWN) + ->toBe(ApplicationStatus::WITHDRAWN) ->and($enum->statusChange(ApplicationStatus::APPLIED)) - ->toBe(ApplicationStatus::INTERVIEWING) + ->toBe(ApplicationStatus::INTERVIEWING) ->and($enum->statusChange(ApplicationStatus::LEAD)) - ->toBe(ApplicationStatus::INTERVIEWING); + ->toBe(ApplicationStatus::INTERVIEWING); }); test('status change must be different than the current', function () {