mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Merge pull request #439 from Hestia-Homes/issue-409-wizard-projects-list
feat(ara-projects): setup wizard 1/5 — projects list, empty state, create-project modal
This commit is contained in:
commit
71b882a13b
22 changed files with 2136 additions and 114 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -10,6 +10,29 @@
|
|||
- **Avoid `useEffect` and `useMemo`.** Derive values inline, prefer Server Components + Route Handlers, prefer event handlers. If a hook is genuinely the only option, flag it and ask before using it.
|
||||
- **Use TanStack Query (`@tanstack/react-query`), not raw `fetch`, for client-side HTTP.** Reads use `useQuery`; writes use `useMutation`. This project is on **v4** — note that `refetchInterval`'s callback signature is `(data, query)`, not v5's `(query)`.
|
||||
|
||||
## Dates
|
||||
|
||||
- **`dd/mm/yyyy` everywhere a person reads or types a date.** This is a UK
|
||||
product. `03/09/2026` is 3 September; rendering it as 3 March is silently
|
||||
wrong data, not a cosmetic bug.
|
||||
- **Never `<input type="date">`.** Its format follows the *browser's* locale,
|
||||
so a US-configured browser shows `mm/dd/yyyy` and no attribute overrides it.
|
||||
Use `DateInput` (`@/app/components/DateInput`) instead — it masks to
|
||||
`dd/mm/yyyy`, carries a calendar picker, and its `value`/`onChange` stay
|
||||
`YYYY-MM-DD`.
|
||||
- **`YYYY-MM-DD` is the only format that crosses a boundary** — form state,
|
||||
request bodies, Drizzle `date` columns. The display format stops at the
|
||||
input's edge. Parsing/formatting helpers are in `@/utils/dates`; for output,
|
||||
pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`.
|
||||
- See [ADR-0019](./docs/adr/0019-uk-date-format-in-form-inputs.md).
|
||||
|
||||
## Utilities
|
||||
|
||||
- **`src/utils/`** is for generic helpers — no domain knowledge (e.g.
|
||||
`dates.ts`). **`src/app/utils.ts`** is for company-specific ones (EPC bands,
|
||||
SAP points, portfolio ratings). `src/lib/utils.ts` is vendored Tremor/shadcn
|
||||
class helpers — don't add to it.
|
||||
|
||||
## Next.js 15 route handlers
|
||||
|
||||
- `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring.
|
||||
|
|
|
|||
235
cypress/e2e/projects/create-project.cy.js
Normal file
235
cypress/e2e/projects/create-project.cy.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Ara Projects — projects list, empty state and create-project modal (#409)
|
||||
*
|
||||
* Two groups, because one of them writes:
|
||||
*
|
||||
* 1. The UI group stubs POST /api/projects with cy.intercept. It exercises
|
||||
* the empty state, the modal, client-side validation and the navigation
|
||||
* to workstream selection without touching the database, so it is safe to
|
||||
* run anywhere.
|
||||
*
|
||||
* 2. The end-to-end group performs a real create and is the acceptance
|
||||
* criterion's "create a project end-to-end". It INSERTs, so it is gated
|
||||
* behind CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever be pointed at
|
||||
* a non-production database. It also needs the #407 reference-data
|
||||
* migration (0274) applied, or the project-type select will be empty and
|
||||
* the page will not offer the CTA at all.
|
||||
*
|
||||
* Like the shell spec, this uses `cy.login` (cypress/support/commands.ts) to
|
||||
* mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set.
|
||||
*
|
||||
* NOT YET RUN. The environment this was written in has DATABASE_URL pointing at
|
||||
* production, so Cypress was deliberately not executed. Treat the selectors and
|
||||
* assertions below as unverified until someone runs them against a scratch DB.
|
||||
*/
|
||||
|
||||
const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
|
||||
const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES");
|
||||
|
||||
const INTERNAL_USER = {
|
||||
email: "dev@domna.homes",
|
||||
name: "Internal User",
|
||||
onboarded: true,
|
||||
sub: "cypress-internal",
|
||||
};
|
||||
|
||||
describe("Ara Projects — create project (stubbed)", function () {
|
||||
beforeEach(function () {
|
||||
if (!JWT_SECRET) {
|
||||
cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
|
||||
this.skip();
|
||||
}
|
||||
cy.login(INTERNAL_USER);
|
||||
});
|
||||
|
||||
it("offers the Create project CTA from the list", function () {
|
||||
cy.visit("/projects");
|
||||
|
||||
// The CTA sits inside the welcome card when there are no projects, and in
|
||||
// the page header once there are — either way there is exactly one.
|
||||
cy.get("[data-testid=create-project-trigger]").should("be.visible").click();
|
||||
cy.get("[data-testid=create-project-form]").should("be.visible");
|
||||
cy.contains("Project details").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows the welcome empty state when the user has no visible projects", function () {
|
||||
// Only meaningful on an instance with no projects visible to this user;
|
||||
// where there are some, the list renders instead and this is skipped.
|
||||
cy.visit("/projects");
|
||||
cy.get("body").then(($body) => {
|
||||
if ($body.find("[data-testid=projects-list-empty]").length === 0) {
|
||||
cy.log("user has visible projects — empty state not applicable");
|
||||
return;
|
||||
}
|
||||
cy.contains("Welcome to Ara Projects").should("be.visible");
|
||||
cy.contains("How setup works").should("be.visible");
|
||||
cy.get("[data-testid=create-project-trigger]").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to submit without a name and a project type", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
|
||||
cy.get("[data-testid=create-project-submit]").click();
|
||||
|
||||
cy.contains("Enter a project name").should("be.visible");
|
||||
cy.get("@createCall").should("not.have.been.called");
|
||||
});
|
||||
|
||||
it("rejects an end date before the start date", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
cy.get("[data-testid=create-project-name]").type("Backwards dates");
|
||||
cy.get("[data-testid=create-project-start-date]").type("30/09/2026");
|
||||
cy.get("[data-testid=create-project-end-date]").type("01/03/2026");
|
||||
cy.get("[data-testid=create-project-submit]").click();
|
||||
|
||||
cy.contains("The end date cannot be before the start date").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
|
||||
it("fills the field day-first from the calendar picker", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
// Type a date first so the grid opens on a known month rather than
|
||||
// whatever month the test happens to run in.
|
||||
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
|
||||
cy.get("[data-testid=create-project-start-date-picker]").click();
|
||||
|
||||
// The grid is en-GB, so the week starts on Monday.
|
||||
cy.get("[role=dialog]").within(() => {
|
||||
cy.contains("Mo").should("be.visible");
|
||||
cy.contains("March 2026").should("be.visible");
|
||||
cy.get("[role=gridcell] button").contains(/^17$/).click();
|
||||
});
|
||||
|
||||
cy.get("[data-testid=create-project-start-date]").should(
|
||||
"have.value",
|
||||
"17/03/2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats the date as it is typed and rejects a day that does not exist", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
// The separators are supplied by the field, so eight digits are enough.
|
||||
cy.get("[data-testid=create-project-start-date]")
|
||||
.type("31022026")
|
||||
.should("have.value", "31/02/2026");
|
||||
|
||||
cy.get("[data-testid=create-project-name]").type("Impossible date");
|
||||
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
|
||||
cy.get("[data-testid=create-project-submit]").click();
|
||||
|
||||
// A finished-but-impossible date must not read as an empty optional field.
|
||||
cy.contains("Enter a valid date").should("be.visible");
|
||||
cy.get("@createCall").should("not.have.been.called");
|
||||
});
|
||||
|
||||
it("navigates to workstream selection on success", function () {
|
||||
cy.intercept("POST", "/api/projects", {
|
||||
statusCode: 201,
|
||||
body: { id: "4242" },
|
||||
}).as("createProject");
|
||||
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
cy.get("[data-testid=create-project-name]").type("Riverside retrofit 2026");
|
||||
selectFirstOption("create-project-organisation");
|
||||
selectFirstOption("create-project-type");
|
||||
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
|
||||
cy.get("[data-testid=create-project-domna-admin-access]").click();
|
||||
cy.get("[data-testid=create-project-submit]").click();
|
||||
|
||||
cy.wait("@createProject").then(({ request }) => {
|
||||
expect(request.body.name).to.eq("Riverside retrofit 2026");
|
||||
expect(request.body.domnaAdminAccess).to.eq(true);
|
||||
// Typed day-first, sent ISO: the display format stops at the input, and
|
||||
// 01/03 is 1 March rather than 3 January.
|
||||
expect(request.body.startDate).to.eq("2026-03-01");
|
||||
});
|
||||
|
||||
cy.location("pathname").should("eq", "/projects/4242/setup/workstreams");
|
||||
});
|
||||
|
||||
it("surfaces the server's error message on a refusal", function () {
|
||||
cy.intercept("POST", "/api/projects", {
|
||||
statusCode: 403,
|
||||
body: { error: "You cannot create a project for that organisation." },
|
||||
}).as("createProject");
|
||||
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
cy.get("[data-testid=create-project-name]").type("Not mine");
|
||||
selectFirstOption("create-project-organisation");
|
||||
selectFirstOption("create-project-type");
|
||||
cy.get("[data-testid=create-project-submit]").click();
|
||||
|
||||
cy.wait("@createProject");
|
||||
cy.get("[data-testid=create-project-error]")
|
||||
.should("be.visible")
|
||||
.and("contain", "cannot create a project for that organisation");
|
||||
cy.location("pathname").should("eq", "/projects");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ara Projects — create project (end to end, writes)", function () {
|
||||
beforeEach(function () {
|
||||
if (!JWT_SECRET || !E2E_WRITES) {
|
||||
cy.log(
|
||||
"ARA_PROJECTS_E2E_WRITES not set — skipping the writing happy path",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
cy.login(INTERNAL_USER);
|
||||
});
|
||||
|
||||
it("creates a project and lands on workstream selection", function () {
|
||||
// Unique per run so repeated runs do not collide on a name a human is
|
||||
// eyeballing later. `project.name` has no unique constraint; this is for
|
||||
// legibility, not correctness.
|
||||
const name = `Cypress project ${Date.now()}`;
|
||||
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
cy.get("[data-testid=create-project-name]").type(name);
|
||||
selectFirstOption("create-project-organisation");
|
||||
selectFirstOption("create-project-type");
|
||||
cy.get("[data-testid=create-project-description]").type(
|
||||
"Created by the Cypress happy path.",
|
||||
);
|
||||
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
|
||||
cy.get("[data-testid=create-project-end-date]").type("30/09/2026");
|
||||
// Required for an internal user creating on behalf of an organisation they
|
||||
// are not a member of — without it the guard refuses, by design.
|
||||
cy.get("[data-testid=create-project-domna-admin-access]").click();
|
||||
|
||||
cy.get("[data-testid=create-project-submit]").click();
|
||||
|
||||
cy.location("pathname").should("match", /^\/projects\/\d+\/setup\/workstreams$/);
|
||||
|
||||
// The new project is now visible in the list, which is the visibility rule
|
||||
// agreeing with the write that just happened.
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=projects-list]").should("exist");
|
||||
cy.contains("[data-testid=projects-list-item]", name).should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pick the first item in a shadcn/Radix `Select`. The options live in a portal
|
||||
* outside the trigger, so this opens the trigger and then reaches for the
|
||||
* listbox at the document root.
|
||||
*/
|
||||
function selectFirstOption(testId) {
|
||||
cy.get(`[data-testid=${testId}]`).click();
|
||||
cy.get("[role=option]").first().click();
|
||||
}
|
||||
|
|
@ -76,7 +76,17 @@ describe("Ara Projects route shell", function () {
|
|||
cy.get("[data-testid=projects-shell]").should("exist");
|
||||
});
|
||||
|
||||
it("renders the per-project dashboard shell and its nav", function () {
|
||||
// This assertion changed with #409. While the authz stub was in place,
|
||||
// `canViewProject` could never return false, so /projects/<anything> rendered
|
||||
// the dashboard shell for any signed-in user — which is what this test used
|
||||
// to check. The real lib (#408) is now wired in, so a project the user has no
|
||||
// standing on is a 404, and a project id that does not exist is the same 404
|
||||
// (deliberately indistinguishable, so Project existence does not leak).
|
||||
//
|
||||
// Rendering the dashboard shell therefore now needs a real, visible project,
|
||||
// which is covered by the create-project happy path in create-project.cy.js
|
||||
// rather than by a hardcoded id here.
|
||||
it("404s on a project the user has no standing on", function () {
|
||||
cy.login({
|
||||
email: "dev@domna.homes",
|
||||
name: "Internal User",
|
||||
|
|
@ -84,13 +94,21 @@ describe("Ara Projects route shell", function () {
|
|||
sub: "cypress-internal",
|
||||
});
|
||||
|
||||
cy.visit("/projects/1");
|
||||
cy.request({ url: "/projects/999999999", failOnStatusCode: false })
|
||||
.its("status")
|
||||
.should("eq", 404);
|
||||
});
|
||||
|
||||
cy.get("[data-testid=project-shell]").should("exist");
|
||||
cy.get("[data-testid=project-dashboard-heading]").should("be.visible");
|
||||
|
||||
["dashboard", "workOrders", "import", "settings"].forEach((item) => {
|
||||
cy.get(`[data-testid=projects-nav-${item}]`).should("be.visible");
|
||||
it("404s on a malformed project id", function () {
|
||||
cy.login({
|
||||
email: "dev@domna.homes",
|
||||
name: "Internal User",
|
||||
onboarded: true,
|
||||
sub: "cypress-internal",
|
||||
});
|
||||
|
||||
cy.request({ url: "/projects/not-an-id", failOnStatusCode: false })
|
||||
.its("status")
|
||||
.should("eq", 404);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
102
docs/adr/0019-uk-date-format-in-form-inputs.md
Normal file
102
docs/adr/0019-uk-date-format-in-form-inputs.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# 19. Dates are entered and displayed as dd/mm/yyyy, not with `<input type="date">`
|
||||
|
||||
Date: 2026-07-22
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
Numbering note: 0015–0017 are in flight on the reporting-redesign branch
|
||||
(PR #416), alongside 0010; 0019 follows ADR-0018 and avoids collision with all
|
||||
of them.
|
||||
|
||||
## Context
|
||||
|
||||
This is a UK product. Everything we *display* already knows that — some forty
|
||||
call sites pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`, so a
|
||||
rendered date reads `3 September 2026` or `03/09/2026`.
|
||||
|
||||
Form **inputs** did not follow. Every date field in the app was
|
||||
`<input type="date">`, and the native control's display format is chosen by the
|
||||
**browser's** locale, not the document's. A developer or user whose Chrome is
|
||||
configured `en-US` sees `mm/dd/yyyy` in the same app that renders `dd/mm/yyyy`
|
||||
everywhere else.
|
||||
|
||||
That inconsistency is not cosmetic. `03/09/2026` is a valid date under both
|
||||
readings — 3 September to a UK reader, 9 March to a US one. A user entering a
|
||||
project's start date has no way to tell which one the box took, and no
|
||||
validation can catch it, because both are dates. The failure mode is silently
|
||||
wrong data written months into a works programme, not an error message.
|
||||
|
||||
The `lang` attribute does not fix this: Chromium and Firefox both take the date
|
||||
input's format from browser/OS locale settings and ignore document language.
|
||||
There is no attribute, CSS property or polyfill that makes the native control
|
||||
render day-first on demand.
|
||||
|
||||
The options were:
|
||||
|
||||
1. **Leave it.** Accept that the entry format varies per viewer.
|
||||
2. **Use the native control but display the format in the label** —
|
||||
"Start date (dd/mm/yyyy)". Honest, but a lie on a US-locale browser, where
|
||||
the box then contradicts its own label.
|
||||
3. **Replace the native control with a masked text input** that reads and
|
||||
writes `dd/mm/yyyy`, keeping `YYYY-MM-DD` as the value, and supply our own
|
||||
calendar to replace the native one it displaces.
|
||||
|
||||
## Decision
|
||||
|
||||
**`dd/mm/yyyy` is the date format for the whole app, in inputs as well as
|
||||
output.** A date shown to or typed by a person is day-first, everywhere, for
|
||||
every user, regardless of their browser's locale.
|
||||
|
||||
**Date form fields use `DateInput` (`src/app/components/DateInput.tsx`), not
|
||||
`<input type="date">`.** It is a text input that masks to `dd/mm/yyyy` as the
|
||||
user types, with a calendar picker in a popover, and whose `value`/`onChange`
|
||||
remain `YYYY-MM-DD`. The parsing and formatting are pure functions in
|
||||
`src/utils/dates.ts` (`formatUkDate`, `parseUkDate`, `formatUkDateWhileTyping`,
|
||||
`isoToLocalDate`, `localDateToIso`), unit-tested independently of React.
|
||||
|
||||
**`YYYY-MM-DD` remains the only format that crosses a boundary** — form state,
|
||||
request bodies, Drizzle `date` columns. The display format stops at the input's
|
||||
edge, so schemas, route handlers and the database are untouched by this
|
||||
decision, and dates keep sorting lexicographically with no timezone in play.
|
||||
|
||||
**Output keeps using `"en-GB"`** with `toLocaleDateString` / `Intl`. This ADR
|
||||
does not introduce a second way to render a date; it makes entry agree with
|
||||
what rendering already did.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The picker is replaced, not lost.** Dropping the native control also drops
|
||||
its calendar, which was judged an acceptable cost — a picker is a
|
||||
convenience, an ambiguous entry format is a correctness problem — but the
|
||||
loss was immediately felt in use, so the calendar was rebuilt *behind*
|
||||
`DateInput` as this ADR anticipated: a popover writing the same ISO value,
|
||||
rather than a revert to the native control. It uses `react-day-picker` via
|
||||
`shadcn_components/ui/calendar.tsx`, defaulting to the `enGB` locale, whose
|
||||
`weekStartsOn: 1` puts Monday first. Typing and picking are equal routes to
|
||||
the same value.
|
||||
- **Two new dependencies**: `react-day-picker` and `date-fns` (the latter was
|
||||
already present transitively; it is now direct). The alternative was a
|
||||
hand-rolled month grid with no dependency, rejected because keyboard
|
||||
navigation and ARIA on a calendar are easy to get subtly wrong and not worth
|
||||
owning.
|
||||
- **The `Date` boundary is confined to two functions.** A calendar works in
|
||||
`Date`s, so `isoToLocalDate` / `localDateToIso` bridge to them — building and
|
||||
reading *local* parts, never `new Date(iso)` or `toISOString()`, both of
|
||||
which shift the day across midnight for a non-zero UTC offset. Everything
|
||||
else in `src/utils/dates.ts` stays string-only. The round trip is tested
|
||||
across a full month.
|
||||
- **Caret handling is a masked input's usual compromise.** Typing at the end of
|
||||
the field, which is the overwhelming case, behaves normally; editing in the
|
||||
middle can move the caret to the end. Judged not worth a masking dependency.
|
||||
- **A finished-but-impossible date is passed to the schema as typed** rather
|
||||
than reported as empty, so `31/02/2026` produces "Enter a valid date"
|
||||
instead of silently reading as an unfilled optional field. Unfinished input
|
||||
reports empty, so no error appears mid-keystroke.
|
||||
- **Migration is incremental.** `DateInput` is used by the create-project modal
|
||||
(#409). Five other `<input type="date">` fields remain, in
|
||||
`PibiSection.tsx`, `PibiDatesEditor.tsx` and `PropertyFilters.tsx`; they are
|
||||
wrong under this ADR and should be converted when next touched.
|
||||
- **New date fields have one right answer**, recorded in CLAUDE.md so it is
|
||||
found before the wrong one is written.
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -46,6 +46,7 @@
|
|||
"aws-sdk": "^2.1415.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"esbuild": "^0.25.8",
|
||||
"eslint-config-next": "13.4.3",
|
||||
|
|
@ -61,6 +62,7 @@
|
|||
"postcss": "^8.5.6",
|
||||
"react": "18.3.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"recharts": "^3.8.1",
|
||||
|
|
@ -1250,6 +1252,12 @@
|
|||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
|
|
@ -6036,12 +6044,36 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/react-day-picker": {
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
|
||||
"integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
@ -8528,9 +8560,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -13283,17 +13315,29 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
|
||||
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"@types/react": ">=16.8.0",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"aws-sdk": "^2.1415.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"esbuild": "^0.25.8",
|
||||
"eslint-config-next": "13.4.3",
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"postcss": "^8.5.6",
|
||||
"react": "18.3.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"recharts": "^3.8.1",
|
||||
|
|
|
|||
80
src/app/api/projects/route.ts
Normal file
80
src/app/api/projects/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { canManageProject } from "@/lib/projects/authz";
|
||||
import {
|
||||
createProjectSchema,
|
||||
prospectiveProjectFacts,
|
||||
} from "@/lib/projects/createProject";
|
||||
import { loadAuthzUserByEmail } from "@/app/repositories/projects/authzRepository";
|
||||
import {
|
||||
insertProject,
|
||||
organisationExists,
|
||||
projectTypeExists,
|
||||
} from "@/app/repositories/projects/projectsRepository";
|
||||
|
||||
/**
|
||||
* POST /api/projects — create an Ara Project (issue #409).
|
||||
*
|
||||
* The authorization question "may this user create a project for this
|
||||
* organisation?" is answered by the *existing* guard: build the facts the
|
||||
* project would have once created and ask `canManageProject`. That keeps
|
||||
* creation on the same rule as every other management action instead of
|
||||
* inventing a parallel one.
|
||||
*
|
||||
* The rule has a consequence worth naming: `domna_admin_access` gates the
|
||||
* `internal` role, so a Domna user creating a project for an organisation they
|
||||
* are not a member of must grant that access. Without it they would be
|
||||
* creating a project they could not then see, and the guard refuses rather
|
||||
* than producing an orphan.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await loadAuthzUserByEmail(session.user.email);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = createProjectSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const draft = parsed.data;
|
||||
|
||||
if (!canManageProject(user, prospectiveProjectFacts(draft))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"You cannot create a project for that organisation. Granting Domna admin access is required for an organisation you are not a member of.",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Checked after authorization so an unauthorized caller cannot use the
|
||||
// difference between 400 and 403 to probe which organisations exist.
|
||||
const projectTypeId = BigInt(draft.projectTypeId);
|
||||
const [orgOk, typeOk] = await Promise.all([
|
||||
organisationExists(draft.organisationId),
|
||||
projectTypeExists(projectTypeId),
|
||||
]);
|
||||
if (!orgOk) {
|
||||
return NextResponse.json({ error: "Unknown organisation" }, { status: 400 });
|
||||
}
|
||||
if (!typeOk) {
|
||||
return NextResponse.json({ error: "Unknown project type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = await insertProject({ ...draft, projectTypeId });
|
||||
|
||||
// `id` is a bigint; JSON cannot carry one, so the client gets a string and
|
||||
// uses it to build the next wizard step's path.
|
||||
return NextResponse.json({ id: id.toString() }, { status: 201 });
|
||||
}
|
||||
166
src/app/components/DateInput.tsx
Normal file
166
src/app/components/DateInput.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Calendar } from "@/app/shadcn_components/ui/calendar";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/app/shadcn_components/ui/popover";
|
||||
import {
|
||||
formatUkDate,
|
||||
formatUkDateWhileTyping,
|
||||
isoToLocalDate,
|
||||
localDateToIso,
|
||||
parseUkDate,
|
||||
} from "@/utils/dates";
|
||||
|
||||
export interface DateInputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange" | "type"
|
||||
> {
|
||||
/**
|
||||
* The date as `YYYY-MM-DD`. Empty, `null` and `undefined` all mean "no date"
|
||||
* — an optional field's form value is `undefined` before it is touched.
|
||||
*/
|
||||
value: string | null | undefined;
|
||||
/**
|
||||
* Called with `YYYY-MM-DD` once a real date has been typed, and `""` while
|
||||
* the field is still incomplete. A *complete* entry that is not a real date
|
||||
* (`31/02/2026`) is passed through as typed, so the caller's schema rejects
|
||||
* it — see the note in the component body.
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
/** Cypress hook. The calendar trigger derives its own from this, suffixed. */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A date field that reads and writes `dd/mm/yyyy`.
|
||||
*
|
||||
* Use this rather than `<input type="date">` anywhere a user enters a date.
|
||||
* The native control's display format follows the *browser's* locale, not the
|
||||
* document's — so a US-configured browser shows `mm/dd/yyyy` on a UK product,
|
||||
* with no attribute available to override it.
|
||||
*
|
||||
* A date can be typed or picked. The calendar in the popover replaces the one
|
||||
* lost with the native control, and writes through the same value, so neither
|
||||
* route is privileged: picking fills the text, typing moves the grid.
|
||||
*
|
||||
* The value crossing this boundary is always `YYYY-MM-DD`, so form state,
|
||||
* request bodies and `date` columns are unaffected — only what the user sees
|
||||
* changes.
|
||||
*/
|
||||
export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
|
||||
function DateInput({ value, onChange, ...props }, ref) {
|
||||
// Normalised first: an untouched optional field is `undefined` and a
|
||||
// cleared one is `""`. Treating them as different values would reset the
|
||||
// text mid-keystroke, because typing reports `""` for incomplete input.
|
||||
const current = value ?? "";
|
||||
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
|
||||
// The displayed text and the value it produced, held together so the pair
|
||||
// can never disagree. Half-typed text ("03/0") has no ISO form, so the
|
||||
// field cannot be driven by `value` alone.
|
||||
const [state, setState] = React.useState(() => ({
|
||||
text: formatUkDate(current),
|
||||
value: current,
|
||||
}));
|
||||
|
||||
// Adjusting state during render, not in an effect: when the form resets or
|
||||
// sets this field from outside, the incoming value is authoritative and
|
||||
// the displayed text is rebuilt from it. React re-runs the render with the
|
||||
// new state before touching the DOM, so this costs no extra paint.
|
||||
if (current !== state.value) {
|
||||
setState({ text: formatUkDate(current), value: current });
|
||||
}
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const raw = event.target.value;
|
||||
const text = formatUkDateWhileTyping(raw, {
|
||||
deleting: raw.length < state.text.length,
|
||||
});
|
||||
|
||||
// Three outcomes, and the middle one is the reason this is not just
|
||||
// `parseUkDate(text) ?? ""`. A field holding "31/02/2026" that reported
|
||||
// itself empty would pass an optional-date schema silently, and the user
|
||||
// would be told nothing about a date they can see in the box. So a
|
||||
// finished-but-impossible date is handed over as typed, where the
|
||||
// schema's `YYYY-MM-DD` check rejects it. Unfinished input is genuinely
|
||||
// "no date yet" and reports empty, so no error appears mid-keystroke.
|
||||
const finished = text.replace(/\D/g, "").length === 8;
|
||||
const next = parseUkDate(text) ?? (finished ? text : "");
|
||||
|
||||
setState({ text, value: next });
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function handleSelect(date: Date | undefined) {
|
||||
const next = date ? localDateToIso(date) : "";
|
||||
setState({ text: formatUkDate(next), value: next });
|
||||
onChange(next);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
// The date the grid should highlight and open on. A half-typed or
|
||||
// impossible entry has none, in which case the picker opens on the current
|
||||
// month rather than refusing to open.
|
||||
const selected = isoToLocalDate(state.value) ?? undefined;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
// `text`, not `date` — see the component's docs.
|
||||
type="text"
|
||||
// Phones get the number pad; the mask supplies the separators.
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="dd/mm/yyyy"
|
||||
maxLength={10}
|
||||
value={state.text}
|
||||
onChange={handleChange}
|
||||
className={cn("pr-10", props.className)}
|
||||
/>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
// Typing stays the primary interaction, so the trigger sits
|
||||
// inside the field rather than beside it: the input keeps the
|
||||
// full width of its grid column, and the layout is unchanged
|
||||
// from the native control it replaced.
|
||||
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={props.disabled}
|
||||
// The field itself is labelled; this button is not, and a bare
|
||||
// icon has no accessible name without one.
|
||||
aria-label="Choose a date from the calendar"
|
||||
data-testid={
|
||||
props["data-testid"]
|
||||
? `${props["data-testid"]}-picker`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
defaultMonth={selected}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { ProjectsNav, type ProjectsNavItem } from "../components/ProjectsNav";
|
||||
import { requireProjectAccess } from "../authz";
|
||||
import { requireProjectAccess } from "../guards";
|
||||
|
||||
function projectNav(projectId: string): ProjectsNavItem[] {
|
||||
const base = `/projects/${projectId}`;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { requireProjectAccess } from "../authz";
|
||||
import { requireProjectAccess } from "../guards";
|
||||
|
||||
export const metadata = {
|
||||
title: "Project dashboard | Ara",
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
/**
|
||||
* Temporary authorization seam for the Ara Projects route tree.
|
||||
*
|
||||
* TODO(#408): replace the bodies below with calls into
|
||||
* `src/lib/projects/authz.ts` once that module lands. This file exists only so
|
||||
* the route shell has a single, obvious place to wire authorization into —
|
||||
* deliberately *not* a second implementation of the rules.
|
||||
*
|
||||
* The visibility rule #408 will enforce:
|
||||
* - internal users (`@domna.homes`) → every Project
|
||||
* - client-org members → Projects where
|
||||
* `project.organisation_id` is their org
|
||||
* - contractor-org members → Projects where their org is assigned
|
||||
* to one of the Project's Workstreams
|
||||
* (`project_workstream_contractor`)
|
||||
*
|
||||
* Note that resolving a user to an organisation does not exist yet either —
|
||||
* that resolution is part of #408, not of this shell.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Guards a Projects route: requires a signed-in user and returns the session.
|
||||
*
|
||||
* Unauthenticated requests to `/projects/*` are already turned away by
|
||||
* `src/middleware.ts`; this is the defence-in-depth check for the server
|
||||
* component itself, matching the `bulk-upload/page.tsx` pattern.
|
||||
*/
|
||||
export async function requireProjectsSession(): Promise<Session> {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session) redirect("/");
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `session`'s user may see the Project `projectId`.
|
||||
*
|
||||
* TODO(#408): delegate to the real visibility rule. Until then this only
|
||||
* asserts a session exists — the shell must not ship its own inlined
|
||||
* permission logic, so it deliberately does not narrow further.
|
||||
*/
|
||||
export async function canViewProject(
|
||||
session: Session,
|
||||
projectId: string
|
||||
): Promise<boolean> {
|
||||
void projectId;
|
||||
return Boolean(session?.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards a single-Project route. Throws the caller into a 404 rather than a
|
||||
* 403 so that Project existence is not leaked to users outside its org.
|
||||
*/
|
||||
export async function requireProjectAccess(projectId: string): Promise<Session> {
|
||||
const session = await requireProjectsSession();
|
||||
if (!(await canViewProject(session, projectId))) {
|
||||
// TODO(#408): notFound() once canViewProject can actually return false.
|
||||
redirect("/projects");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
332
src/app/projects/components/CreateProjectDialog.tsx
Normal file
332
src/app/projects/components/CreateProjectDialog.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
createProjectSchema,
|
||||
type CreateProjectFormValues,
|
||||
type SelectOption,
|
||||
} from "@/lib/projects/createProject";
|
||||
import { DateInput } from "@/app/components/DateInput";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/app/shadcn_components/ui/form";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
|
||||
/**
|
||||
* The HTTP call, kept outside the component so the component itself contains
|
||||
* no `fetch` — TanStack Query owns the request lifecycle.
|
||||
*
|
||||
* Route handlers in this codebase answer errors as `{ error: string }`, so a
|
||||
* failure is surfaced with the server's own message rather than a generic one.
|
||||
*/
|
||||
async function createProject(
|
||||
values: CreateProjectFormValues,
|
||||
): Promise<{ id: string }> {
|
||||
const response = await fetch("/api/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error ?? "Could not create the project");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface CreateProjectDialogProps {
|
||||
organisations: SelectOption[];
|
||||
projectTypes: SelectOption[];
|
||||
/** Rendered as the trigger's label — "Create project" everywhere so far. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create-project modal — step 1 of the setup wizard.
|
||||
*
|
||||
* On success it navigates to workstream selection (#410) rather than back to
|
||||
* the list: creating a project is the start of setup, not the end of it.
|
||||
*/
|
||||
export function CreateProjectDialog({
|
||||
organisations,
|
||||
projectTypes,
|
||||
label = "Create project",
|
||||
}: CreateProjectDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<CreateProjectFormValues>({
|
||||
resolver: zodResolver(createProjectSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
// A client user typically belongs to exactly one organisation, so the
|
||||
// select is pre-filled and effectively fixed. Internal users, offered
|
||||
// every organisation, start with no choice made.
|
||||
organisationId: organisations.length === 1 ? organisations[0].id : "",
|
||||
projectTypeId: "",
|
||||
description: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
domnaAdminAccess: false,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(createProject, {
|
||||
onSuccess: ({ id }) => router.push(`/projects/${id}/setup/workstreams`),
|
||||
});
|
||||
|
||||
// Deliberately not reset on close: a user who dismissed the dialog by
|
||||
// accident gets their typing back. A completed creation navigates away, so
|
||||
// stale values are never shown against a project that already exists.
|
||||
function onOpenChange(next: boolean) {
|
||||
if (!next && mutation.isLoading) return;
|
||||
setOpen(next);
|
||||
if (next) mutation.reset();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onOpenChange(true)}
|
||||
data-testid="create-project-trigger"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Project details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Step 1 of 5 — name the project and choose who it belongs to. You
|
||||
will pick its workstreams next.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
className="space-y-4"
|
||||
data-testid="create-project-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoFocus
|
||||
placeholder="e.g. Riverside retrofit 2026"
|
||||
data-testid="create-project-name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organisationId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={organisations.length <= 1}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger data-testid="create-project-organisation">
|
||||
<SelectValue placeholder="Select client..." />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{organisations.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{organisations.length === 1 && (
|
||||
<FormDescription>
|
||||
Projects you create belong to your organisation.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger data-testid="create-project-type">
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{projectTypes.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
rows={3}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="What is this programme of works for?"
|
||||
data-testid="create-project-description"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="startDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Start date</FormLabel>
|
||||
<FormControl>
|
||||
<DateInput
|
||||
{...field}
|
||||
data-testid="create-project-start-date"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Estimated end date</FormLabel>
|
||||
<FormControl>
|
||||
<DateInput
|
||||
{...field}
|
||||
data-testid="create-project-end-date"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domnaAdminAccess"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
data-testid="create-project-domna-admin-access"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Grant Domna admin access</FormLabel>
|
||||
<FormDescription>
|
||||
Domna admins can help configure this project, support
|
||||
imports, and view project data. This can be revoked at
|
||||
any time.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{mutation.isError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm text-red-600"
|
||||
data-testid="create-project-error"
|
||||
>
|
||||
{(mutation.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isLoading}
|
||||
data-testid="create-project-submit"
|
||||
>
|
||||
{mutation.isLoading ? "Creating…" : "Create and continue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/app/projects/guards.ts
Normal file
88
src/app/projects/guards.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Route guards for the Ara Projects tree.
|
||||
*
|
||||
* This is glue, not policy. It resolves the three things a server component
|
||||
* cannot get from a pure function — the NextAuth session, the user's
|
||||
* organisation memberships, and the project's facts — hands them to the real
|
||||
* guards in `@/lib/projects/authz`, and turns a refusal into a Next.js
|
||||
* navigation. It contains **no permission rules of its own**; every decision
|
||||
* below is a call into that module.
|
||||
*
|
||||
* Layering: `@/lib/projects/authz` decides (pure, no db),
|
||||
* `@/app/repositories/projects/authzRepository` loads (Drizzle), this file
|
||||
* navigates (redirect / notFound). The dependency only ever points that way.
|
||||
*/
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import type { Session } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import {
|
||||
loadAuthzUserByEmail,
|
||||
loadProjectAuthzFacts,
|
||||
} from "@/app/repositories/projects/authzRepository";
|
||||
import {
|
||||
canViewProject,
|
||||
type AuthzUser,
|
||||
type ProjectAuthzFacts,
|
||||
} from "@/lib/projects/authz";
|
||||
|
||||
/**
|
||||
* Requires a signed-in user and returns the session.
|
||||
*
|
||||
* Unauthenticated requests to `/projects/*` are already turned away by
|
||||
* `src/middleware.ts`; this is the defence-in-depth check for the server
|
||||
* component itself, matching the `bulk-upload/page.tsx` pattern. The redirect
|
||||
* target matches the middleware's (`/`, the sign-in page).
|
||||
*/
|
||||
export async function requireProjectsSession(): Promise<Session> {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) redirect("/");
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a signed-in user and resolves them to an `AuthzUser` — the shape
|
||||
* every guard in `@/lib/projects/authz` takes.
|
||||
*
|
||||
* A session whose email has no `user` row is treated as no session at all: it
|
||||
* cannot be authorized against anything, so it goes back to sign-in rather
|
||||
* than falling through to an empty-membership user that silently sees nothing.
|
||||
*/
|
||||
export async function requireProjectsUser(): Promise<AuthzUser> {
|
||||
const session = await requireProjectsSession();
|
||||
const user = await loadAuthzUserByEmail(session.user.email!);
|
||||
if (!user) redirect("/");
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `[projectId]` path segment into the bigint PK it addresses.
|
||||
*
|
||||
* Returns null for anything that is not a bare non-negative integer, so a junk
|
||||
* segment becomes a 404 rather than a Drizzle error.
|
||||
*/
|
||||
export function parseProjectId(projectId: string): bigint | null {
|
||||
return /^\d+$/.test(projectId) ? BigInt(projectId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards a single-Project route, returning the facts the page will need.
|
||||
*
|
||||
* A project the user may not see is a 404, not a 403 — Project existence must
|
||||
* not leak to users outside its organisation, so "no such project" and "not
|
||||
* yours" are deliberately indistinguishable.
|
||||
*/
|
||||
export async function requireProjectAccess(projectId: string): Promise<{
|
||||
user: AuthzUser;
|
||||
project: ProjectAuthzFacts;
|
||||
}> {
|
||||
const user = await requireProjectsUser();
|
||||
|
||||
const id = parseProjectId(projectId);
|
||||
if (id === null) notFound();
|
||||
|
||||
const project = await loadProjectAuthzFacts(id);
|
||||
if (!project || !canViewProject(user, project)) notFound();
|
||||
|
||||
return { user, project };
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { ProjectsNav, type ProjectsNavItem } from "./components/ProjectsNav";
|
||||
import { requireProjectsSession } from "./authz";
|
||||
import { requireProjectsSession } from "./guards";
|
||||
|
||||
const TOP_LEVEL_NAV: ProjectsNavItem[] = [
|
||||
{ label: "Projects", href: "/projects", icon: "projects", exact: true },
|
||||
|
|
|
|||
|
|
@ -1,49 +1,194 @@
|
|||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { requireProjectsSession } from "./authz";
|
||||
import Link from "next/link";
|
||||
import { FolderOpenIcon } from "@heroicons/react/24/outline";
|
||||
import { requireProjectsUser } from "./guards";
|
||||
import { CreateProjectDialog } from "./components/CreateProjectDialog";
|
||||
import {
|
||||
listOrganisationOptions,
|
||||
listProjectTypes,
|
||||
listVisibleProjects,
|
||||
} from "@/app/repositories/projects/projectsRepository";
|
||||
import type { ProjectListItem } from "@/lib/projects/createProject";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
|
||||
export const metadata = {
|
||||
title: "Projects | Ara",
|
||||
};
|
||||
|
||||
/** The wizard's shape, shown on the empty state so setup is not a surprise. */
|
||||
const SETUP_STEPS = [
|
||||
{
|
||||
title: "Project details",
|
||||
body: "Name the project, choose the client organisation and the type of works.",
|
||||
},
|
||||
{
|
||||
title: "Workstreams and stages",
|
||||
body: "Pick the categories of work and the stage ladder each one follows.",
|
||||
},
|
||||
{
|
||||
title: "Documents and permissions",
|
||||
body: "Say what evidence each workstream needs, and who may upload it.",
|
||||
},
|
||||
{
|
||||
title: "Import and validate work orders",
|
||||
body: "Bring in the work orders and check them before they go live.",
|
||||
},
|
||||
];
|
||||
|
||||
function formatDateRange(project: ProjectListItem): string | null {
|
||||
const format = (value: string) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${value}T00:00:00Z`));
|
||||
|
||||
if (project.startDate && project.endDate) {
|
||||
return `${format(project.startDate)} — ${format(project.endDate)}`;
|
||||
}
|
||||
if (project.startDate) return `From ${format(project.startDate)}`;
|
||||
if (project.endDate) return `Until ${format(project.endDate)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects list.
|
||||
* Projects list — the entry point to Ara Projects (issue #409).
|
||||
*
|
||||
* Stub for now — the real list (and its visibility rule: internal users see
|
||||
* everything, client-org members see their org's Projects, contractor-org
|
||||
* members see Projects their org is assigned to a Workstream on) arrives with
|
||||
* the list issue on top of the authorization work in #408.
|
||||
* A Server Component: the visibility rule is enforced before anything reaches
|
||||
* the browser, so a project the user may not see is never serialised to it.
|
||||
* Only the create-project modal is client-side, and it is handed its select
|
||||
* options as props rather than fetching them — the server already knows which
|
||||
* organisations this user may choose.
|
||||
*/
|
||||
export default async function ProjectsListPage() {
|
||||
await requireProjectsSession();
|
||||
const user = await requireProjectsUser();
|
||||
|
||||
const [projects, organisations, projectTypes] = await Promise.all([
|
||||
listVisibleProjects(user),
|
||||
listOrganisationOptions(user),
|
||||
listProjectTypes(),
|
||||
]);
|
||||
|
||||
// A user with no organisation to create *for*, or an instance whose
|
||||
// reference data has not been seeded, cannot complete the form — so the CTA
|
||||
// is not offered. (The route handler refuses these cases regardless; this
|
||||
// only avoids presenting a dead end.)
|
||||
const canCreate = organisations.length > 0 && projectTypes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-10">
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Ara Projects
|
||||
</p>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Works programmes owned by your organisation.
|
||||
</p>
|
||||
<div className="mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Ara Projects
|
||||
</p>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Works programmes owned by your organisation.
|
||||
</p>
|
||||
</div>
|
||||
{projects.length > 0 && canCreate && (
|
||||
<CreateProjectDialog
|
||||
organisations={organisations}
|
||||
projectTypes={projectTypes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-24 border-2 border-dashed border-gray-200 rounded-2xl text-center"
|
||||
data-testid="projects-list-empty"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<Squares2X2Icon className="h-7 w-7 text-gray-400" />
|
||||
{projects.length === 0 ? (
|
||||
<div data-testid="projects-list-empty">
|
||||
<Card className="text-center py-12">
|
||||
<CardHeader>
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-2">
|
||||
<FolderOpenIcon className="h-7 w-7 text-gray-400" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Welcome to Ara Projects</CardTitle>
|
||||
<CardDescription className="max-w-md mx-auto">
|
||||
You don't have any active projects yet. Start by creating a
|
||||
project to organise your workstreams, track progress and manage
|
||||
contractors.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{canCreate && (
|
||||
<CardContent className="flex justify-center">
|
||||
<CreateProjectDialog
|
||||
organisations={organisations}
|
||||
projectTypes={projectTypes}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="mt-10">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-4">
|
||||
How setup works
|
||||
</h2>
|
||||
<ol className="grid gap-3 sm:grid-cols-2">
|
||||
{SETUP_STEPS.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className="flex gap-3 rounded-xl border border-gray-200 p-4"
|
||||
>
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{step.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base font-semibold text-gray-700 mb-1">
|
||||
No projects to show yet
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
The projects list is not built yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3" data-testid="projects-list">
|
||||
{projects.map((project) => {
|
||||
const dates = formatDateRange(project);
|
||||
return (
|
||||
<li key={project.id}>
|
||||
<Link
|
||||
href={`/projects/${project.id}`}
|
||||
className="block rounded-xl border border-gray-200 p-5 hover:border-gray-300 hover:bg-gray-50 transition-colors"
|
||||
data-testid="projects-list-item"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-base font-semibold text-gray-900">
|
||||
{project.name}
|
||||
</h2>
|
||||
<span className="shrink-0 text-xs font-medium text-gray-500 bg-gray-100 rounded-full px-2.5 py-1">
|
||||
{project.projectTypeName}
|
||||
</span>
|
||||
</div>
|
||||
{project.organisationName && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{project.organisationName}
|
||||
</p>
|
||||
)}
|
||||
{project.description && (
|
||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
{dates && (
|
||||
<p className="text-xs text-gray-400 mt-3">{dates}</p>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,28 @@ export async function loadAuthzUser(userId: bigint): Promise<AuthzUser | null> {
|
|||
return { id: found.id, email: found.email, organisationIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a user plus their organisation memberships, keyed by email.
|
||||
*
|
||||
* Server components and route handlers hold a NextAuth session, and the only
|
||||
* identifier that session reliably carries is the email: `session.user.dbId`
|
||||
* is populated by the `jwt` callback at initial sign-in only, so sessions
|
||||
* issued before that field existed have no id at all. Email is unique on
|
||||
* `user`, so this is the same row `loadAuthzUser` would return.
|
||||
*/
|
||||
export async function loadAuthzUserByEmail(
|
||||
email: string,
|
||||
): Promise<AuthzUser | null> {
|
||||
const [found] = await db
|
||||
.select({ id: user.id, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!found) return null;
|
||||
return { ...found, organisationIds: await getUserOrganisations(found.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one project's permission-relevant facts, including every organisation
|
||||
* assigned as contractor to any of its workstreams. Returns null when the
|
||||
|
|
|
|||
192
src/app/repositories/projects/projectsRepository.ts
Normal file
192
src/app/repositories/projects/projectsRepository.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Ara Projects persistence for the projects list and the create-project flow
|
||||
* (issue #409).
|
||||
*
|
||||
* Same boundary as `./authzRepository`: Drizzle lives here, decisions live in
|
||||
* `@/lib/projects/*`. Nothing in this file decides who may see what — it loads
|
||||
* facts and hands them to `canViewProject`.
|
||||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import {
|
||||
project,
|
||||
projectType,
|
||||
projectWorkstream,
|
||||
projectWorkstreamContractor,
|
||||
} from "@/app/db/schema/projects/projects";
|
||||
import {
|
||||
canViewProject,
|
||||
isInternalEmail,
|
||||
type AuthzUser,
|
||||
type ProjectAuthzFacts,
|
||||
} from "@/lib/projects/authz";
|
||||
import type { ProjectListItem, SelectOption } from "@/lib/projects/createProject";
|
||||
|
||||
/**
|
||||
* Every project the user may see, newest first.
|
||||
*
|
||||
* The visibility rule is **not** restated in SQL. Doing so would mean writing
|
||||
* the `domna_admin_access OR owning-org OR contractor-org` disjunction a second
|
||||
* time, in a second language, free to drift from `resolveProjectRole`. Instead
|
||||
* this loads each project's facts and asks `canViewProject` — so the rule has
|
||||
* exactly one definition, and the acceptance criteria (client sees its own,
|
||||
* contractor sees what it is assigned to, internal sees all subject to
|
||||
* `domna_admin_access`) are satisfied by construction.
|
||||
*
|
||||
* The cost is that the filtering happens in the app rather than the database,
|
||||
* over two round trips and no N+1. That is the right trade at v1 scale, where
|
||||
* a project is a works programme and they number in the tens. If the table
|
||||
* grows by orders of magnitude, the fix is a **superset** prefilter in SQL —
|
||||
* narrowing the rows loaded while still letting `canViewProject` make the
|
||||
* final call — not a reimplementation of the rule in a WHERE clause.
|
||||
*/
|
||||
export async function listVisibleProjects(
|
||||
user: AuthzUser,
|
||||
): Promise<ProjectListItem[]> {
|
||||
const [rows, contractorRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
organisationId: project.organisationId,
|
||||
organisationName: organisation.name,
|
||||
domnaAdminAccess: project.domnaAdminAccess,
|
||||
projectTypeName: projectType.name,
|
||||
})
|
||||
.from(project)
|
||||
.innerJoin(projectType, eq(project.projectTypeId, projectType.id))
|
||||
.leftJoin(organisation, eq(project.organisationId, organisation.id))
|
||||
.orderBy(desc(project.id)),
|
||||
db
|
||||
.selectDistinct({
|
||||
projectId: projectWorkstream.projectId,
|
||||
organisationId: projectWorkstreamContractor.organisationId,
|
||||
})
|
||||
.from(projectWorkstreamContractor)
|
||||
.innerJoin(
|
||||
projectWorkstream,
|
||||
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
|
||||
),
|
||||
]);
|
||||
|
||||
const contractorsByProject = new Map<string, string[]>();
|
||||
for (const row of contractorRows) {
|
||||
const key = row.projectId.toString();
|
||||
const existing = contractorsByProject.get(key);
|
||||
if (existing) existing.push(row.organisationId);
|
||||
else contractorsByProject.set(key, [row.organisationId]);
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row) => {
|
||||
const facts: ProjectAuthzFacts = {
|
||||
id: row.id,
|
||||
organisationId: row.organisationId,
|
||||
domnaAdminAccess: row.domnaAdminAccess,
|
||||
contractorOrganisationIds:
|
||||
contractorsByProject.get(row.id.toString()) ?? [],
|
||||
};
|
||||
return canViewProject(user, facts);
|
||||
})
|
||||
.map((row) => ({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
projectTypeName: row.projectTypeName,
|
||||
organisationName: row.organisationName,
|
||||
description: row.description,
|
||||
startDate: row.startDate,
|
||||
endDate: row.endDate,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The seeded project types, for the modal's Project type select. */
|
||||
export async function listProjectTypes(): Promise<SelectOption[]> {
|
||||
const rows = await db
|
||||
.select({ id: projectType.id, name: projectType.name })
|
||||
.from(projectType)
|
||||
.orderBy(asc(projectType.name));
|
||||
|
||||
return rows.map((r) => ({ id: r.id.toString(), name: r.name }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The organisations the user may create a project *for*.
|
||||
*
|
||||
* Internal users pick any organisation; everyone else is confined to the ones
|
||||
* they belong to — which for a typical client user is exactly one, so the
|
||||
* select is effectively fixed. This only shapes the choices offered: the
|
||||
* server still re-checks the submitted organisation against
|
||||
* `canManageProject`, because a hidden option is not a permission.
|
||||
*
|
||||
* `organisation.name` is nullable in the schema; unnamed rows are dropped
|
||||
* rather than rendered as a blank option nobody can identify.
|
||||
*/
|
||||
export async function listOrganisationOptions(
|
||||
user: AuthzUser,
|
||||
): Promise<SelectOption[]> {
|
||||
if (!isInternalEmail(user.email) && user.organisationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: organisation.id, name: organisation.name })
|
||||
.from(organisation)
|
||||
.where(
|
||||
isInternalEmail(user.email)
|
||||
? undefined
|
||||
: inArray(organisation.id, user.organisationIds),
|
||||
)
|
||||
.orderBy(asc(organisation.name));
|
||||
|
||||
return rows.flatMap((r) => (r.name ? [{ id: r.id, name: r.name }] : []));
|
||||
}
|
||||
|
||||
/** Whether a project type id names a seeded row. */
|
||||
export async function projectTypeExists(id: bigint): Promise<boolean> {
|
||||
const [found] = await db
|
||||
.select({ id: projectType.id })
|
||||
.from(projectType)
|
||||
.where(eq(projectType.id, id))
|
||||
.limit(1);
|
||||
return Boolean(found);
|
||||
}
|
||||
|
||||
/** Whether an organisation id names a real organisation. */
|
||||
export async function organisationExists(id: string): Promise<boolean> {
|
||||
const [found] = await db
|
||||
.select({ id: organisation.id })
|
||||
.from(organisation)
|
||||
.where(eq(organisation.id, id))
|
||||
.limit(1);
|
||||
return Boolean(found);
|
||||
}
|
||||
|
||||
/** Insert the project row and return its new id. */
|
||||
export async function insertProject(values: {
|
||||
name: string;
|
||||
organisationId: string;
|
||||
projectTypeId: bigint;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
domnaAdminAccess: boolean;
|
||||
}): Promise<bigint> {
|
||||
const [created] = await db
|
||||
.insert(project)
|
||||
.values({
|
||||
name: values.name,
|
||||
organisationId: values.organisationId,
|
||||
projectTypeId: values.projectTypeId,
|
||||
description: values.description ?? null,
|
||||
startDate: values.startDate ?? null,
|
||||
endDate: values.endDate ?? null,
|
||||
domnaAdminAccess: values.domnaAdminAccess,
|
||||
})
|
||||
.returning({ id: project.id });
|
||||
|
||||
return created.id;
|
||||
}
|
||||
92
src/app/shadcn_components/ui/calendar.tsx
Normal file
92
src/app/shadcn_components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { DayPicker, type DayPickerProps } from "react-day-picker";
|
||||
import { enGB } from "react-day-picker/locale";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/app/shadcn_components/ui/button";
|
||||
|
||||
export type CalendarProps = DayPickerProps;
|
||||
|
||||
/**
|
||||
* The shadcn calendar, on react-day-picker v10.
|
||||
*
|
||||
* Written by hand rather than pulled from the shadcn registry: the published
|
||||
* template targets v8/v9, whose `classNames` keys differ from the `UI` enum
|
||||
* this version uses. The keys below are v10's.
|
||||
*
|
||||
* Defaults to `enGB`, which is the reason this wrapper exists rather than
|
||||
* `DayPicker` being used directly — the locale carries `weekStartsOn: 1`, so
|
||||
* the grid starts on Monday and the weekday headers read Mo–Su, matching how
|
||||
* the rest of the app formats dates.
|
||||
*
|
||||
* `fixedWeeks` is a default rather than a stylistic choice. A month needs
|
||||
* either five or six week rows depending on the weekday it starts on — August
|
||||
* 2026 needs six, July and September five — and a grid that changes height
|
||||
* mid-navigation makes the popover holding it jump: Radix re-runs collision
|
||||
* detection on resize, so one extra row is enough to flip the calendar from
|
||||
* below the field to above it and back again as the user pages through months.
|
||||
* Six rows always means a constant height, so there is nothing to react to.
|
||||
*/
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
fixedWeeks = true,
|
||||
locale = enGB,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
locale={locale}
|
||||
showOutsideDays={showOutsideDays}
|
||||
fixedWeeks={fixedWeeks}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-4",
|
||||
month: "flex flex-col gap-4",
|
||||
month_caption: "flex justify-center pt-1 relative items-center h-7",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1 absolute right-1 top-1 z-10",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative focus-within:relative focus-within:z-20",
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
selected:
|
||||
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:focus]:bg-primary",
|
||||
today: "[&>button]:bg-accent [&>button]:text-accent-foreground",
|
||||
outside: "[&>button]:text-muted-foreground [&>button]:opacity-50",
|
||||
disabled: "[&>button]:text-muted-foreground [&>button]:opacity-50",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation, className: chevronClassName, ...rest }) => {
|
||||
const Icon =
|
||||
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
|
||||
return <Icon className={cn("h-4 w-4", chevronClassName)} {...rest} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
163
src/lib/projects/createProject.test.ts
Normal file
163
src/lib/projects/createProject.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectSchema,
|
||||
prospectiveProjectFacts,
|
||||
} from "./createProject";
|
||||
import { canManageProject, type AuthzUser } from "./authz";
|
||||
|
||||
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
|
||||
const OTHER_ORG = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
function validDraft(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "Riverside retrofit 2026",
|
||||
organisationId: CLIENT_ORG,
|
||||
projectTypeId: "1",
|
||||
description: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
domnaAdminAccess: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUser(overrides: Partial<AuthzUser> = {}): AuthzUser {
|
||||
return {
|
||||
id: 1n,
|
||||
email: "someone@landlord.example",
|
||||
organisationIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createProjectSchema", () => {
|
||||
it("accepts a minimal draft", () => {
|
||||
const parsed = createProjectSchema.parse(validDraft());
|
||||
expect(parsed.name).toBe("Riverside retrofit 2026");
|
||||
expect(parsed.organisationId).toBe(CLIENT_ORG);
|
||||
expect(parsed.domnaAdminAccess).toBe(false);
|
||||
});
|
||||
|
||||
it("normalises the untouched optional fields to undefined", () => {
|
||||
// An untouched <input> submits "", which must not be written to the column
|
||||
// as an empty string masquerading as a description or a date.
|
||||
const parsed = createProjectSchema.parse(validDraft());
|
||||
expect(parsed.description).toBeUndefined();
|
||||
expect(parsed.startDate).toBeUndefined();
|
||||
expect(parsed.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims and keeps a real description", () => {
|
||||
const parsed = createProjectSchema.parse(
|
||||
validDraft({ description: " Phase one " }),
|
||||
);
|
||||
expect(parsed.description).toBe("Phase one");
|
||||
});
|
||||
|
||||
it("rejects a blank or whitespace-only name", () => {
|
||||
expect(createProjectSchema.safeParse(validDraft({ name: "" })).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
createProjectSchema.safeParse(validDraft({ name: " " })).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an organisation id that is not a uuid", () => {
|
||||
expect(
|
||||
createProjectSchema.safeParse(validDraft({ organisationId: "acme" })).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric project type id", () => {
|
||||
expect(
|
||||
createProjectSchema.safeParse(validDraft({ projectTypeId: "retrofit" }))
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed date", () => {
|
||||
expect(
|
||||
createProjectSchema.safeParse(validDraft({ startDate: "01/02/2026" }))
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an end date on or after the start date", () => {
|
||||
for (const endDate of ["2026-03-01", "2026-09-30"]) {
|
||||
const parsed = createProjectSchema.safeParse(
|
||||
validDraft({ startDate: "2026-03-01", endDate }),
|
||||
);
|
||||
expect(parsed.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an end date before the start date, against the end field", () => {
|
||||
const parsed = createProjectSchema.safeParse(
|
||||
validDraft({ startDate: "2026-09-30", endDate: "2026-03-01" }),
|
||||
);
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success) {
|
||||
expect(parsed.error.issues[0].path).toEqual(["endDate"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows one date without the other", () => {
|
||||
expect(
|
||||
createProjectSchema.safeParse(validDraft({ startDate: "2026-03-01" }))
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createProjectSchema.safeParse(validDraft({ endDate: "2026-03-01" })).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prospectiveProjectFacts", () => {
|
||||
it("lets a member of the owning organisation create the project", () => {
|
||||
const user = makeUser({ organisationIds: [CLIENT_ORG] });
|
||||
const draft = createProjectSchema.parse(validDraft());
|
||||
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a user with no standing on the chosen organisation", () => {
|
||||
const user = makeUser({ organisationIds: [OTHER_ORG] });
|
||||
const draft = createProjectSchema.parse(validDraft());
|
||||
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets an internal user create for any organisation when access is granted", () => {
|
||||
const user = makeUser({ email: "dev@domna.homes" });
|
||||
const draft = createProjectSchema.parse(
|
||||
validDraft({ domnaAdminAccess: true }),
|
||||
);
|
||||
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an internal non-member who withholds Domna admin access", () => {
|
||||
// `domna_admin_access` gates the internal role, so this draft would create
|
||||
// a project its own author could not then see. Refusing is the rule
|
||||
// working, not a gap in it.
|
||||
const user = makeUser({ email: "dev@domna.homes" });
|
||||
const draft = createProjectSchema.parse(
|
||||
validDraft({ domnaAdminAccess: false }),
|
||||
);
|
||||
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(false);
|
||||
});
|
||||
|
||||
it("still lets an internal user who is a genuine member create without the toggle", () => {
|
||||
const user = makeUser({
|
||||
email: "dev@domna.homes",
|
||||
organisationIds: [CLIENT_ORG],
|
||||
});
|
||||
const draft = createProjectSchema.parse(
|
||||
validDraft({ domnaAdminAccess: false }),
|
||||
);
|
||||
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(true);
|
||||
});
|
||||
|
||||
it("grants nobody a contractor role on a project that has no workstreams yet", () => {
|
||||
const draft = createProjectSchema.parse(validDraft());
|
||||
expect(prospectiveProjectFacts(draft).contractorOrganisationIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
94
src/lib/projects/createProject.ts
Normal file
94
src/lib/projects/createProject.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* The create-project draft: what the form collects, what the route handler
|
||||
* accepts, and how it is turned into facts the authz guards can judge.
|
||||
*
|
||||
* Pure and db-free, like `./authz` — the schema is imported by both the client
|
||||
* form (as a `zodResolver`) and `POST /api/projects` (as the request-body
|
||||
* parser), so the two cannot drift into disagreeing about what a valid draft
|
||||
* is. The client validates for the user's benefit; the server validates
|
||||
* because the client cannot be trusted.
|
||||
*
|
||||
* Everything crossing the wire is a string: `project_type.id` is a bigint that
|
||||
* JSON cannot carry, and `project.start_date` / `end_date` are Drizzle `date`
|
||||
* columns, which round-trip as `YYYY-MM-DD`.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { ProjectAuthzFacts } from "./authz";
|
||||
|
||||
/** `YYYY-MM-DD`, the wire and column format for the two date fields. */
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* An optional free-text or date field. Empty strings are what an untouched
|
||||
* `<input>` submits, so they are normalised to `undefined` rather than being
|
||||
* written to the column as `""`.
|
||||
*/
|
||||
const optionalText = (schema: z.ZodString) =>
|
||||
z
|
||||
.union([schema, z.literal("")])
|
||||
.optional()
|
||||
.transform((value) => (value ? value : undefined));
|
||||
|
||||
export const createProjectSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Enter a project name").max(255),
|
||||
organisationId: z.string().uuid("Select a client organisation"),
|
||||
projectTypeId: z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Select a project type"),
|
||||
description: optionalText(z.string().trim().max(2000)),
|
||||
startDate: optionalText(z.string().regex(ISO_DATE, "Enter a valid date")),
|
||||
endDate: optionalText(z.string().regex(ISO_DATE, "Enter a valid date")),
|
||||
domnaAdminAccess: z.boolean(),
|
||||
})
|
||||
// Lexicographic comparison is exact for zero-padded ISO dates, so this needs
|
||||
// no Date parsing (and no timezone to get wrong).
|
||||
.refine(
|
||||
(draft) => !draft.startDate || !draft.endDate || draft.endDate >= draft.startDate,
|
||||
{ message: "The end date cannot be before the start date", path: ["endDate"] },
|
||||
);
|
||||
|
||||
/** The validated draft — the output type, after empty strings are dropped. */
|
||||
export type CreateProjectDraft = z.output<typeof createProjectSchema>;
|
||||
|
||||
/** The form's working type — the input type, before the transforms run. */
|
||||
export type CreateProjectFormValues = z.input<typeof createProjectSchema>;
|
||||
|
||||
/**
|
||||
* The `ProjectAuthzFacts` a draft *would* have once created, so the same
|
||||
* guards that police an existing project can police its creation — there is no
|
||||
* separate "can create" rule to keep in step.
|
||||
*
|
||||
* `id` is a placeholder: the row has no key until it is inserted, and no guard
|
||||
* in `./authz` reads it. `contractorOrganisationIds` is genuinely empty — a
|
||||
* brand-new project has no workstreams, so nobody holds a contractor role on
|
||||
* it and only the client (or an internal user the draft grants access to) can
|
||||
* pass `canManageProject`.
|
||||
*/
|
||||
export function prospectiveProjectFacts(
|
||||
draft: Pick<CreateProjectDraft, "organisationId" | "domnaAdminAccess">,
|
||||
): ProjectAuthzFacts {
|
||||
return {
|
||||
id: 0n,
|
||||
organisationId: draft.organisationId,
|
||||
domnaAdminAccess: draft.domnaAdminAccess,
|
||||
contractorOrganisationIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A project as the list page renders it. Ids are strings for the same reason. */
|
||||
export interface ProjectListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
projectTypeName: string;
|
||||
organisationName: string | null;
|
||||
description: string | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
/** An option in the modal's Client / Project type selects. */
|
||||
export interface SelectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
155
src/utils/dates.test.ts
Normal file
155
src/utils/dates.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatUkDate,
|
||||
formatUkDateWhileTyping,
|
||||
isoToLocalDate,
|
||||
localDateToIso,
|
||||
parseUkDate,
|
||||
} from "./dates";
|
||||
|
||||
describe("formatUkDate", () => {
|
||||
it("renders an ISO date day-first", () => {
|
||||
expect(formatUkDate("2026-09-03")).toBe("03/09/2026");
|
||||
});
|
||||
|
||||
it("keeps the leading zeros", () => {
|
||||
expect(formatUkDate("2026-01-05")).toBe("05/01/2026");
|
||||
});
|
||||
|
||||
it("returns empty for null, undefined and the empty string", () => {
|
||||
expect(formatUkDate(null)).toBe("");
|
||||
expect(formatUkDate(undefined)).toBe("");
|
||||
expect(formatUkDate("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for a date that is not ISO", () => {
|
||||
expect(formatUkDate("03/09/2026")).toBe("");
|
||||
expect(formatUkDate("2026-9-3")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for a day that does not exist", () => {
|
||||
expect(formatUkDate("2026-02-31")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUkDate", () => {
|
||||
it("reads a UK date as day-first", () => {
|
||||
expect(parseUkDate("03/09/2026")).toBe("2026-09-03");
|
||||
});
|
||||
|
||||
it("ignores surrounding whitespace", () => {
|
||||
expect(parseUkDate(" 03/09/2026 ")).toBe("2026-09-03");
|
||||
});
|
||||
|
||||
it("returns null for partial input", () => {
|
||||
expect(parseUkDate("")).toBeNull();
|
||||
expect(parseUkDate("03")).toBeNull();
|
||||
expect(parseUkDate("03/09")).toBeNull();
|
||||
expect(parseUkDate("03/09/20")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an American date rather than silently transposing it", () => {
|
||||
// 09/30/2026 is 30 September to a US reader; as dd/mm it has month 30.
|
||||
expect(parseUkDate("09/30/2026")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects days that do not exist", () => {
|
||||
expect(parseUkDate("31/02/2026")).toBeNull();
|
||||
expect(parseUkDate("31/04/2026")).toBeNull();
|
||||
expect(parseUkDate("00/01/2026")).toBeNull();
|
||||
expect(parseUkDate("01/13/2026")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts 29 February only in a leap year", () => {
|
||||
expect(parseUkDate("29/02/2024")).toBe("2024-02-29");
|
||||
expect(parseUkDate("29/02/2026")).toBeNull();
|
||||
expect(parseUkDate("29/02/2000")).toBe("2000-02-29");
|
||||
expect(parseUkDate("29/02/1900")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips with formatUkDate", () => {
|
||||
expect(formatUkDate(parseUkDate("03/09/2026"))).toBe("03/09/2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isoToLocalDate / localDateToIso", () => {
|
||||
it("builds local midnight, not UTC midnight", () => {
|
||||
const date = isoToLocalDate("2026-03-01")!;
|
||||
expect(date.getFullYear()).toBe(2026);
|
||||
expect(date.getMonth()).toBe(2); // zero-based: March
|
||||
expect(date.getDate()).toBe(1);
|
||||
expect(date.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for anything that is not a real ISO date", () => {
|
||||
expect(isoToLocalDate("")).toBeNull();
|
||||
expect(isoToLocalDate(null)).toBeNull();
|
||||
expect(isoToLocalDate(undefined)).toBeNull();
|
||||
expect(isoToLocalDate("01/03/2026")).toBeNull();
|
||||
expect(isoToLocalDate("2026-02-31")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the local parts back", () => {
|
||||
expect(localDateToIso(new Date(2026, 2, 1))).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
it("pads single-digit months and days", () => {
|
||||
expect(localDateToIso(new Date(2026, 0, 5))).toBe("2026-01-05");
|
||||
});
|
||||
|
||||
it("round-trips every day of a month regardless of the host timezone", () => {
|
||||
// The bug this guards is a date shifting by one day through a UTC
|
||||
// conversion, which only shows up on some days in some zones — so check
|
||||
// them all rather than a single sample.
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const iso = `2026-03-${String(day).padStart(2, "0")}`;
|
||||
expect(localDateToIso(isoToLocalDate(iso)!)).toBe(iso);
|
||||
}
|
||||
});
|
||||
|
||||
it("survives a date whose UTC form is the previous day", () => {
|
||||
// 1 March at local midnight is 28 February in UTC for any negative
|
||||
// offset. toISOString() would report the 28th; this must not.
|
||||
const date = new Date(2026, 2, 1);
|
||||
expect(localDateToIso(date)).toBe("2026-03-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUkDateWhileTyping", () => {
|
||||
const typed = (raw: string) => formatUkDateWhileTyping(raw, { deleting: false });
|
||||
const deleted = (raw: string) => formatUkDateWhileTyping(raw, { deleting: true });
|
||||
|
||||
it("adds each separator as soon as its group is complete", () => {
|
||||
expect(typed("0")).toBe("0");
|
||||
expect(typed("03")).toBe("03/");
|
||||
expect(typed("03/0")).toBe("03/0");
|
||||
expect(typed("03/09")).toBe("03/09/");
|
||||
expect(typed("03/09/2026")).toBe("03/09/2026");
|
||||
});
|
||||
|
||||
it("accepts digits typed without any separators", () => {
|
||||
expect(typed("03092026")).toBe("03/09/2026");
|
||||
});
|
||||
|
||||
it("discards anything that is not a digit", () => {
|
||||
expect(typed("3rd Sept")).toBe("3");
|
||||
expect(typed("03-09-2026")).toBe("03/09/2026");
|
||||
});
|
||||
|
||||
it("stops at eight digits", () => {
|
||||
expect(typed("030920261234")).toBe("03/09/2026");
|
||||
});
|
||||
|
||||
it("lets a separator be deleted instead of re-adding it", () => {
|
||||
// Backspacing over "03/" must land on "03", not bounce back to "03/".
|
||||
expect(deleted("03")).toBe("03");
|
||||
expect(deleted("03/09")).toBe("03/09");
|
||||
});
|
||||
|
||||
it("keeps deleting all the way to empty", () => {
|
||||
expect(deleted("03/0")).toBe("03/0");
|
||||
expect(deleted("03/")).toBe("03");
|
||||
expect(deleted("0")).toBe("0");
|
||||
expect(deleted("")).toBe("");
|
||||
});
|
||||
});
|
||||
134
src/utils/dates.ts
Normal file
134
src/utils/dates.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* UK date entry and display.
|
||||
*
|
||||
* Generic: this file knows about calendars and string formats, and nothing
|
||||
* about our domain. Domain-specific helpers live in `@/app/utils`.
|
||||
*
|
||||
* Two formats, and they never mix:
|
||||
*
|
||||
* - **`dd/mm/yyyy` is what a person sees and types.** This is a UK product;
|
||||
* `03/09/2026` means 3 September, and a form that renders it as 3 March is
|
||||
* not a cosmetic bug — the user cannot tell they have entered the wrong
|
||||
* date, so it is silently wrong data.
|
||||
* - **`YYYY-MM-DD` is what everything else uses** — form state, request
|
||||
* bodies, and Drizzle `date` columns. It sorts lexicographically, carries no
|
||||
* timezone, and is the only format that crosses a boundary.
|
||||
*
|
||||
* The parsing side is deliberately `Date`-free, so there is no timezone to get
|
||||
* wrong: `new Date("2026-03-01")` is midnight UTC, which in a negative-offset
|
||||
* zone is the previous day. We compare and build strings.
|
||||
*
|
||||
* See ADR-0019 for why date form inputs are not `<input type="date">`.
|
||||
*/
|
||||
|
||||
/** `YYYY-MM-DD` — the wire and column format. */
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/** `dd/mm/yyyy` — the format a person reads and types. */
|
||||
const UK_DATE = /^(\d{2})\/(\d{2})\/(\d{4})$/;
|
||||
|
||||
/** Days in each month, index 0 = January. February is corrected below. */
|
||||
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
/** Proleptic Gregorian, which is what the `date` column stores. */
|
||||
function isLeapYear(year: number): boolean {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parts name a day that exists — `31/02/2026` and `31/04/2026` do
|
||||
* not. Checked explicitly because `Date` would roll them forward into March
|
||||
* and May rather than reject them.
|
||||
*/
|
||||
function isRealDate(year: number, month: number, day: number): boolean {
|
||||
if (month < 1 || month > 12 || day < 1) return false;
|
||||
const limit = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
|
||||
return day <= limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` → `dd/mm/yyyy`, for showing a stored date to a person.
|
||||
*
|
||||
* Anything that is not a real ISO date — including `null`, `undefined` and the
|
||||
* empty string an untouched field holds — becomes `""`, so a caller can render
|
||||
* the result directly without a null check.
|
||||
*/
|
||||
export function formatUkDate(iso: string | null | undefined): string {
|
||||
const match = iso?.match(ISO_DATE);
|
||||
if (!match) return "";
|
||||
const [, year, month, day] = match;
|
||||
if (!isRealDate(Number(year), Number(month), Number(day))) return "";
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `dd/mm/yyyy` → `YYYY-MM-DD`, for turning what a person typed into the format
|
||||
* everything else uses.
|
||||
*
|
||||
* Returns `null` for anything that is not a complete, real UK date — partial
|
||||
* input (`01/0`), a transposed pair that looks American (`09/30/2026`, whose
|
||||
* month is 30), and days that do not exist (`31/02/2026`). Callers treat
|
||||
* `null` as "not a date yet", not as "empty".
|
||||
*/
|
||||
export function parseUkDate(text: string): string | null {
|
||||
const match = text.trim().match(UK_DATE);
|
||||
if (!match) return null;
|
||||
const [, day, month, year] = match;
|
||||
if (!isRealDate(Number(year), Number(month), Number(day))) return null;
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` → a `Date` at **local** midnight, for the calendar picker.
|
||||
*
|
||||
* This is the one place a `Date` is unavoidable — a calendar component works
|
||||
* in `Date`s — so it is also the one place the timezone trap has to be
|
||||
* handled. `new Date("2026-03-01")` parses as midnight *UTC*, which west of
|
||||
* Greenwich is the evening of 29 February, and a calendar comparing it against
|
||||
* local days would highlight the wrong one. Passing the parts separately
|
||||
* builds local midnight instead, which is what the grid compares against.
|
||||
*
|
||||
* Returns `null` for anything that is not a real ISO date.
|
||||
*/
|
||||
export function isoToLocalDate(iso: string | null | undefined): Date | null {
|
||||
const match = iso?.match(ISO_DATE);
|
||||
if (!match) return null;
|
||||
const [, year, month, day] = match;
|
||||
if (!isRealDate(Number(year), Number(month), Number(day))) return null;
|
||||
return new Date(Number(year), Number(month) - 1, Number(day));
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Date` → `YYYY-MM-DD`, reading the **local** parts.
|
||||
*
|
||||
* The inverse of {@link isoToLocalDate}, and it must not go via `toISOString`,
|
||||
* which converts to UTC and would shift the date across midnight for any
|
||||
* non-zero offset — the same bug in the opposite direction.
|
||||
*/
|
||||
export function localDateToIso(date: Date): string {
|
||||
const year = String(date.getFullYear()).padStart(4, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformat a half-typed `dd/mm/yyyy` field after every keystroke: keep the
|
||||
* digits, drop everything else, and regroup.
|
||||
*
|
||||
* The separator is added as soon as a group is complete so the user does not
|
||||
* have to type it — but only while they are *adding* characters. Re-adding it
|
||||
* during a backspace would make the slash undeletable: the keystroke that
|
||||
* removed it would immediately put it back, and the caret would never get past
|
||||
* it.
|
||||
*/
|
||||
export function formatUkDateWhileTyping(
|
||||
raw: string,
|
||||
{ deleting }: { deleting: boolean }
|
||||
): string {
|
||||
const digits = raw.replace(/\D/g, "").slice(0, 8);
|
||||
const groups = [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 8)];
|
||||
const text = groups.filter((group) => group.length > 0).join("/");
|
||||
const completesAGroup = digits.length === 2 || digits.length === 4;
|
||||
return !deleting && completesAGroup ? `${text}/` : text;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue