From a7a1f8ee982c88d21473d6a6ff506598ea83a6ca Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 21 Jul 2026 16:52:53 +0000 Subject: [PATCH] feat(ara-projects): projects list, empty state and create-project modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup wizard step 1/5 (#409). The list is a Server Component, so the visibility rule runs before anything reaches the browser and a project the user may not see is never serialised to it. The rule itself is not restated: `listVisibleProjects` loads each project's facts and asks `canViewProject` from `@/lib/projects/authz`. Writing the `domna_admin_access OR owning-org OR contractor-org` disjunction a second time in SQL would have given it two definitions free to drift, and the acceptance criteria (client sees its own, contractor sees what it is assigned to, internal sees all subject to `domna_admin_access`) fall out of the one definition instead of being re-derived. The cost is filtering in the app rather than the database, over two round trips and no N+1. That is the right trade while a project is a works programme and they number in the tens; if the table grows, the fix is a superset prefilter that still lets `canViewProject` decide, not a WHERE clause that re-implements it. Flagged in the function's own docs. Creation reuses the same guard rather than inventing a "can create" rule: `prospectiveProjectFacts` builds the facts the project *would* have, and `canManageProject` judges them. One consequence is worth knowing, and is covered by a test — `domna_admin_access` gates the `internal` role, so a Domna user creating 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. The modal offers only organisations the user may pick, but the route handler re-checks the submitted one: a hidden option is not a permission. `createProjectSchema` is shared by the form (as a zodResolver) and the route handler (as the body parser), so the two cannot disagree about what a valid draft is. Empty strings from untouched inputs normalise to undefined rather than reaching the columns as "". Also updates projects-shell.cy.js. Its "renders the per-project dashboard shell" test passed only because the authz stub could never return false; with the real lib wired in, /projects/ is a 404 without standing on that project, so the test now asserts that instead. TESTS: the vitest unit tests pass (50, no database connection). Cypress was NOT run — this environment's DATABASE_URL points at production. The new spec is therefore unverified, and its end-to-end half, which INSERTs, is gated behind CYPRESS_ARA_PROJECTS_E2E_WRITES so it cannot fire by accident; it also needs the #407 seed migration (0274) applied, which has not been run against any database yet either. Co-Authored-By: Claude Opus 4.8 (1M context) --- cypress/e2e/projects/create-project.cy.js | 191 ++++++++++ cypress/e2e/projects/projects-shell.cy.js | 32 +- src/app/api/projects/route.ts | 80 +++++ .../components/CreateProjectDialog.tsx | 333 ++++++++++++++++++ src/app/projects/page.tsx | 207 +++++++++-- .../projects/projectsRepository.ts | 192 ++++++++++ src/lib/projects/createProject.test.ts | 163 +++++++++ src/lib/projects/createProject.ts | 94 +++++ 8 files changed, 1254 insertions(+), 38 deletions(-) create mode 100644 cypress/e2e/projects/create-project.cy.js create mode 100644 src/app/api/projects/route.ts create mode 100644 src/app/projects/components/CreateProjectDialog.tsx create mode 100644 src/app/repositories/projects/projectsRepository.ts create mode 100644 src/lib/projects/createProject.test.ts create mode 100644 src/lib/projects/createProject.ts diff --git a/cypress/e2e/projects/create-project.cy.js b/cypress/e2e/projects/create-project.cy.js new file mode 100644 index 00000000..c868632e --- /dev/null +++ b/cypress/e2e/projects/create-project.cy.js @@ -0,0 +1,191 @@ +/** + * 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("2026-09-30"); + cy.get("[data-testid=create-project-end-date]").type("2026-03-01"); + cy.get("[data-testid=create-project-submit]").click(); + + cy.contains("The end date cannot be before the start date").should( + "be.visible", + ); + }); + + 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-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); + }); + + 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("2026-03-01"); + cy.get("[data-testid=create-project-end-date]").type("2026-09-30"); + // 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(); +} diff --git a/cypress/e2e/projects/projects-shell.cy.js b/cypress/e2e/projects/projects-shell.cy.js index 2676d9da..6e484d68 100644 --- a/cypress/e2e/projects/projects-shell.cy.js +++ b/cypress/e2e/projects/projects-shell.cy.js @@ -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/ 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); }); }); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 00000000..8e0c3bd1 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -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 }); +} diff --git a/src/app/projects/components/CreateProjectDialog.tsx b/src/app/projects/components/CreateProjectDialog.tsx new file mode 100644 index 00000000..505713a8 --- /dev/null +++ b/src/app/projects/components/CreateProjectDialog.tsx @@ -0,0 +1,333 @@ +"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 { 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({ + 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 ( + <> + + + + + + Project details + + Step 1 of 5 — name the project and choose who it belongs to. You + will pick its workstreams next. + + + +
+ mutation.mutate(values))} + className="space-y-4" + data-testid="create-project-form" + > + ( + + Project name + + + + + + )} + /> + + ( + + Client + + {organisations.length === 1 && ( + + Projects you create belong to your organisation. + + )} + + + )} + /> + + ( + + Project type + + + + )} + /> + + ( + + Description + +