Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .ai/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---
150 changes: 111 additions & 39 deletions .ai/features/v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions app/Enums/ApplicationStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
37 changes: 37 additions & 0 deletions tests/Unit/ApplicationStatusTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use App\Enums\ApplicationStatus;

describe('the enum can only move forward', function () {
test('an interviewing state can not move backward', function () {
$enum = ApplicationStatus::INTERVIEWING;
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);
});
});