diff --git a/.ai/decisions.md b/.ai/decisions.md index 384a125..434af7f 100644 --- a/.ai/decisions.md +++ b/.ai/decisions.md @@ -11,3 +11,57 @@ 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`. + +--- + +## `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_ + +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..1866ee1 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 + +`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 + 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) 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 new file mode 100644 index 0000000..e84f4c9 --- /dev/null +++ b/tests/Unit/ApplicationStatusTest.php @@ -0,0 +1,37 @@ +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); + }); +});