Project CRUD
Full lifecycle management for construction projects — create, list, view, update, and archive.
Create Project
Admins create projects via a dialog that collects:
- Name (required, 1-255 chars)
- Client (free text)
- Business Unit (dropdown, fetched from org’s business units)
- Location (address string, max 500 chars)
- Beacon ID (external reference)
- Award Date / Completion Date (YYYY-MM-DD)
- Value (amount + 3-char currency code, default AUD)
- Initial Stages (optional, created inline during project creation)
Flow
- User clicks “New Project” in the projects table
- Dialog opens, business units loaded via
useQuery - User fills form, submits
- Server Action validates with Zod, calls
POST /v1/orgs/{orgId}/projects - Go handler verifies admin role, creates project with
CreateProjectsqlc query - If stages provided, inserts each via
AddProjectStage - Returns full project detail
- Client navigates to
/org-dashboard/{orgId}/projects/{projectId}
List Projects
The default dashboard view. Admins see all projects in the org; non-admins see only projects they are assigned to.
Query Strategy
Admin: ListProjectsByOrgID + CountProjectsByOrgID
Non-admin: ListProjectsByUserAssignment + CountProjectsByUserAssignmentThe user assignment queries use INNER JOIN project_members to filter to assigned projects only.
Filters
| Parameter | Type | Description |
|---|---|---|
status | string | live or archived (empty = all) |
search | string | Substring match on project name (ILIKE) |
page | int | Page number (default 1) |
page_size | int | Items per page (default 25, max 100) |
Table Columns
Name, Client, Status (badge), Business Unit, Award Date, Completion Date, Member Count.
Row click navigates to the project detail page.
View Project Detail
Fetches the project with its nested stages via the GetProjectByID query, with a LEFT JOIN business_units for the unit name. The detail carries no member list: the Members tab reads GET …/members when it is opened, and person pickers read GET …/members/options.
Tabs
- Overview — All project metadata in a
dlgrid. Inline edit form toggles between view and edit modes. - Stages — Append-only stage list with “Add Stage” form. See Project Stages.
- Members — Member list with roles and permissions, loaded when the tab opens. See Project Members.
Update Project
Partial updates via PUT /v1/orgs/{orgId}/projects/{projectId}. Uses the COALESCE pattern in SQL:
UPDATE projects
SET name = COALESCE(sqlc.narg('name'), name),
geo_location = COALESCE(sqlc.narg('geo_location'), geo_location),
-- ... all optional fields
WHERE id = @id AND org_id = @org_id AND deleted_at IS NULL
RETURNING *;Only fields included in the request body are updated. Omitted fields retain their current values.
Inline Edit Flow
- User clicks “Edit” on the Overview tab
- View switches to a form pre-filled with current values
- User modifies fields and clicks “Save Changes”
- Server Action sends only changed fields to
PUTendpoint - Go handler validates admin role, runs
UpdateProjectquery - Page refreshes with updated data
Archive Project
Soft-delete via DELETE /v1/orgs/{orgId}/projects/{projectId}:
- User clicks “Archive” button, confirms via browser dialog
- Server Action calls
DELETEendpoint - Go handler sets
deleted_at = NOW()viaSoftDeleteProjectquery - User redirected back to projects list
- Archived projects excluded from default list (filtered by
deleted_at IS NULL)
Archived projects can be viewed via the status=archived filter.
API Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/orgs/{orgId}/projects | List (paginated, filtered) |
POST | /v1/orgs/{orgId}/projects | Create (admin only) |
GET | /v1/orgs/{orgId}/projects/{projectId} | Detail (stages included; no members) |
PUT | /v1/orgs/{orgId}/projects/{projectId} | Partial update (admin only) |
DELETE | /v1/orgs/{orgId}/projects/{projectId} | Soft-delete (admin only) |
Security
- Admin-only mutations — Create, update, and delete require
master_adminororganisational_managerrole. - Tenant isolation — All queries include
AND org_id = $N. Handler verifies JWTorg_idmatches URL{orgId}. - Non-admin access — Detail endpoint checks
IsUserProjectMemberbefore returning data. - Soft-delete — No data is permanently removed.
deleted_at IS NULLfilter applied to all active queries. - COALESCE updates — Prevents accidental field overwriting during partial updates.
Key Files
| File | Purpose |
|---|---|
backend/api/internal/project/handler.go | HTTP handlers for all project CRUD endpoints |
backend/api/internal/project/service.go | Business logic: create, list, update, delete |
backend/db/queries/projects.sql | sqlc queries with pagination and role-based filtering |
apps/web/src/actions/projects.ts | BFF server actions with Zod validation |
apps/web/src/app/org-dashboard/[orgId]/page.tsx | Projects list page (RSC + Suspense) |
apps/web/src/app/org-dashboard/[orgId]/_components/projects-table.tsx | Table with search, filters, pagination |
apps/web/src/app/org/_components/create-project-wizard.tsx | Create project wizard with BU lookup |
apps/web/src/app/org-dashboard/[orgId]/projects/[projectId]/_components/project-detail.tsx | Detail with tabs |
What a new project gets automatically
Creating a project performs several best-effort seeds. None of them can fail project creation — each logs a warning and continues.
| Seed | Detail |
|---|---|
| Issue lifecycle statuses | The five fixed statuses: Identified, Notified, Submitted, Assessed, Agreed. Each is seeded carrying its own lifecycle_stage — the stored word that decides which of the issue’s five dates advancing to it stamps. The statuses are the stages, so the mapping is settled here and is not configurable: no request body accepts lifecycle_stage, and Issue Settings → Statuses shows the name plus the notification checkboxes, nothing else. Because it is stored rather than read off the title, renaming a status cannot change which date it writes. NULL means the status sits outside the lifecycle and stamps nothing. |
| Org custom field templates | Every org-level template is copied to the project. |
| Auto-members | Configured auto_member_roles, plus the Business Unit’s members when one is set. |
| Join-project QR token | One permanent field_join_tokens row, so QR Manager shows Join This Project immediately. |
The join QR
Previously the QR only existed once someone visited Field Users and clicked Show QR code — QR Manager derives the surface from the presence of a field_join_tokens row, so a project nobody had opened Field Users for had no join QR at all.
The seeded token never expires (it uses the same 9999-12-31 sentinel as a manually generated permanent QR) and carries a 7-character invite code, so a field user can either scan or type it. Its internal row label is Project join — auto-created; QR Manager’s displayed label is always Join This Project.
The QR belongs to the project, not to a person. created_by records the admin who created the project, taken from the verified session. It is only a record: the column is nullable and its foreign key to users is ON DELETE SET NULL. Deleting or deactivating that person (or deleting their organisation) never deletes, hides or invalidates the token, so a printed sign keeps working and QR Manager keeps showing the same QR. If the session carries no usable user id, the token is still seeded, with no creator recorded.
Generating another QR does not break this one. QR Manager shows the most recently created token that is neither revoked nor expired, so a later manual generation takes over the display, but the seeded token is not revoked and stays fully redeemable. A sign printed from it keeps working.
Expiry is part of that check for a reason. A time-limited token that has lapsed is skipped rather than displayed: token validation requires expires_at > NOW(), so an expired row would otherwise render a QR that scans and then fails — and, because the newest match wins, it would hide the permanent token sitting behind it.
Existing projects were backfilled by a one-off migration, which gave one permanent token to every live, non-deleted project in an active org that held no active token. Projects in an org with no users were skipped, since at the time created_by could not be empty.