1250 lines
56 KiB
Markdown
1250 lines
56 KiB
Markdown
# Perauset Village Operations Platform - Implementation Plan
|
|
|
|
## Current State
|
|
|
|
- Repo: `/Users/yasser/git/perauset`
|
|
- Pikku monorepo (Yarn 4, TypeScript, PostgreSQL)
|
|
- Backend on port 6002 (uWebSockets + Pikku)
|
|
- One migration `sql/0001-init.sql` creating `app.user` table
|
|
- One function: `health-check.function.ts`
|
|
- Kysely for DB types, CFWorkerSchemaService for validation
|
|
- DB types generated via `kysely-codegen` with `--default-schema app`
|
|
|
|
---
|
|
|
|
## Architectural Decisions
|
|
|
|
### Migration Numbering
|
|
|
|
Continue sequential from existing `0001-init.sql`:
|
|
- `0002-roles-permissions.sql`
|
|
- `0003-audit-log.sql`
|
|
- `0004-stays.sql`
|
|
- `0005-retreats.sql`
|
|
- `0006-rooms.sql`
|
|
- `0007-boats.sql`
|
|
- `0008-tasks.sql`
|
|
- `0009-kitchen-dietary.sql`
|
|
- `0010-inventory.sql`
|
|
- `0011-notifications.sql`
|
|
- `0012-seed-data.sql`
|
|
|
|
All tables go in the `app` schema. After each migration batch, run `yarn workspace @perauset/functions db:types` to regenerate `db.types.ts`.
|
|
|
|
### Folder Organization within `packages/functions/src/`
|
|
|
|
```
|
|
packages/functions/src/
|
|
application-types.d.ts # UserSession, Config, Services types
|
|
config.ts # createConfig
|
|
services.ts # createSingletonServices
|
|
middleware.ts # CORS etc
|
|
db.types.ts # Generated by kysely-codegen
|
|
|
|
functions/
|
|
health-check.function.ts # Existing
|
|
|
|
auth/
|
|
get-session.function.ts
|
|
login.function.ts
|
|
|
|
users/
|
|
get-user.function.ts
|
|
update-user-profile.function.ts
|
|
list-users.function.ts
|
|
|
|
roles/
|
|
assign-role.function.ts
|
|
revoke-role.function.ts
|
|
list-roles.function.ts
|
|
list-user-roles.function.ts
|
|
|
|
stays/
|
|
request-stay.function.ts
|
|
review-stay-request.function.ts
|
|
approve-stay-request.function.ts
|
|
reject-stay-request.function.ts
|
|
create-stay-from-request.function.ts
|
|
check-in-stay.function.ts
|
|
check-out-stay.function.ts
|
|
extend-stay.function.ts
|
|
cancel-stay.function.ts
|
|
override-stay-dates.function.ts
|
|
list-stays.function.ts
|
|
list-stay-requests.function.ts
|
|
|
|
rooms/
|
|
create-room.function.ts
|
|
block-room.function.ts
|
|
request-room-preference.function.ts
|
|
allocate-room.function.ts
|
|
reallocate-room.function.ts
|
|
release-room.function.ts
|
|
cancel-room-allocation.function.ts
|
|
list-rooms.function.ts
|
|
list-room-allocations.function.ts
|
|
|
|
boats/
|
|
create-boat-route.function.ts
|
|
create-boat.function.ts
|
|
create-boat-trip.function.ts
|
|
open-boat-trip.function.ts
|
|
request-boat-seat.function.ts
|
|
review-boat-request.function.ts
|
|
confirm-boat-request.function.ts
|
|
waitlist-boat-request.function.ts
|
|
decline-boat-request.function.ts
|
|
cancel-boat-request.function.ts
|
|
board-manifest-entry.function.ts
|
|
complete-boat-trip.function.ts
|
|
list-boat-trips.function.ts
|
|
list-boat-requests.function.ts
|
|
|
|
tasks/
|
|
create-task-template.function.ts
|
|
generate-recurring-tasks.function.ts
|
|
create-task-instance.function.ts
|
|
assign-task.function.ts
|
|
claim-task.function.ts
|
|
accept-task-assignment.function.ts
|
|
decline-task-assignment.function.ts
|
|
start-task.function.ts
|
|
complete-task.function.ts
|
|
mark-task-blocked.function.ts
|
|
reassign-task.function.ts
|
|
list-tasks.function.ts
|
|
|
|
retreats/
|
|
create-retreat.function.ts
|
|
update-retreat.function.ts
|
|
add-retreat-person.function.ts
|
|
update-retreat-person-role.function.ts
|
|
link-stay-to-retreat-person.function.ts
|
|
publish-retreat.function.ts
|
|
add-retreat-schedule-item.function.ts
|
|
update-retreat-schedule-item.function.ts
|
|
cancel-retreat.function.ts
|
|
list-retreats.function.ts
|
|
|
|
kitchen/
|
|
upsert-dietary-profile.function.ts
|
|
create-meal-service.function.ts
|
|
estimate-meal-attendance.function.ts
|
|
adjust-meal-attendance.function.ts
|
|
list-meal-services.function.ts
|
|
|
|
inventory/
|
|
request-inventory.function.ts
|
|
review-inventory-request.function.ts
|
|
fulfill-inventory-request.function.ts
|
|
list-inventory.function.ts
|
|
|
|
notifications/
|
|
list-notifications.function.ts
|
|
mark-notification-read.function.ts
|
|
|
|
audit/
|
|
list-audit-log.function.ts
|
|
|
|
lib/
|
|
audit-context.ts # Shared helper: sets app.current_user_id on Kysely connection per-request
|
|
permissions.ts # Tag-based permissions: addPermission('stays.review', [...]) registered per tag
|
|
state-machine.ts # Shared helper: validateTransition(entity, fromStatus, toStatus)
|
|
errors.ts # Domain-specific error classes
|
|
```
|
|
|
|
Each function file follows the existing Pikku pattern: export a function using `pikkuFunc` or `pikkuSessionlessFunc`, define routes with `defineHTTPRoutes`, and wire them with `wireHTTPRoutes`.
|
|
|
|
### Function Convention: Zod Schemas + Rich Metadata
|
|
|
|
**All functions must use Zod schemas for input and output** with `.describe()` on every field. Functions must include `description` and `tags` metadata. This ensures AI agents (MCP, RPC, etc.) can understand and use the API effectively.
|
|
|
|
**Pattern**:
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { pikkuFunc, defineHTTPRoutes, wireHTTPRoutes } from '#pikku/pikku-types.gen.js'
|
|
|
|
export const ApproveStayRequestInput = z.object({
|
|
requestId: z.string().uuid().describe('The stay request ID to approve'),
|
|
reviewNotes: z.string().optional().describe('Optional notes from the reviewer explaining the approval'),
|
|
})
|
|
|
|
export const ApproveStayRequestOutput = z.object({
|
|
requestId: z.string().uuid().describe('The approved stay request ID'),
|
|
status: z.string().describe('New status of the request (approved)'),
|
|
})
|
|
|
|
export const approveStayRequest = pikkuFunc({
|
|
description: 'Approve a submitted or under-review stay request. Transitions status to approved.',
|
|
tags: ['stays.review'],
|
|
auth: true,
|
|
input: ApproveStayRequestInput,
|
|
output: ApproveStayRequestOutput,
|
|
func: async ({ kysely }, { requestId, reviewNotes }, { session }) => {
|
|
// ...implementation
|
|
},
|
|
})
|
|
|
|
export const approveStayRequestRoutes = defineHTTPRoutes({
|
|
auth: true,
|
|
routes: {
|
|
approveStayRequest: {
|
|
route: '/stays/requests/:requestId/approve',
|
|
method: 'post',
|
|
func: approveStayRequest,
|
|
},
|
|
},
|
|
})
|
|
|
|
wireHTTPRoutes({ routes: { approveStayRequest: approveStayRequestRoutes } })
|
|
```
|
|
|
|
**Rules**:
|
|
- Every Zod field gets `.describe('...')` with a human-readable explanation
|
|
- Every function gets `description` explaining what it does, state transitions, and required permissions
|
|
- Every function gets `tags` for categorization (domain module + role scope)
|
|
- Input/output schemas are exported as named constants (e.g., `ApproveStayRequestInput`) for reuse
|
|
- Use `z.string().uuid()` for IDs, `z.string().email()` for emails, `z.coerce.date()` for dates, etc.
|
|
|
|
### E2E Test Approach
|
|
|
|
**Runner**: Cucumber.js with `tsx` for TypeScript. Gherkin `.feature` files for readable specs, step definitions in TypeScript. API-only tests (no browser/Playwright).
|
|
|
|
**Structure**:
|
|
```
|
|
e2e/
|
|
package.json
|
|
tsconfig.json
|
|
tests/
|
|
cucumber.mjs # Cucumber config
|
|
support/
|
|
api-client.ts # HTTP client wrapping fetch, handles auth cookies
|
|
config.ts # API URL (http://localhost:6099), test DB name (perauset_test)
|
|
hooks.ts # BeforeAll: start server + run migrations + seed; AfterAll: stop server
|
|
world.ts # Custom World with api client, auth state, helper methods
|
|
db.ts # Direct pg Client for assertions and cleanup
|
|
features/
|
|
phase-0/
|
|
health-check.feature
|
|
users.feature
|
|
roles-permissions.feature
|
|
audit-log.feature
|
|
phase-1/
|
|
stays.feature
|
|
rooms.feature
|
|
boats.feature
|
|
tasks.feature
|
|
notifications.feature
|
|
phase-2/
|
|
retreats.feature
|
|
kitchen.feature
|
|
inventory.feature
|
|
steps/
|
|
phase-0/
|
|
health-check.steps.ts
|
|
users.steps.ts
|
|
roles-permissions.steps.ts
|
|
audit-log.steps.ts
|
|
phase-1/
|
|
stays.steps.ts
|
|
rooms.steps.ts
|
|
boats.steps.ts
|
|
tasks.steps.ts
|
|
notifications.steps.ts
|
|
phase-2/
|
|
retreats.steps.ts
|
|
kitchen.steps.ts
|
|
inventory.steps.ts
|
|
```
|
|
|
|
**Server lifecycle**: The `hooks.ts` BeforeAll spawns the backend server process (`tsx backend/bin/start.ts`) on port 6099, runs migrations against a `perauset_test` database, seeds initial data. AfterAll kills the server process.
|
|
|
|
**Auth for tests**: Uses `@pikku/auth-js`. The world.ts creates an auth session by calling the Auth.js CSRF + credentials callback flow (same pattern as makeanagent e2e), then passes session cookies on all API calls.
|
|
|
|
**API calls**: `fetch()` wrapped in `api-client.ts` with base URL, JSON headers, and session cookie forwarding. Methods: `apiGet(path)`, `apiPost(path, body)`, `apiPut(path, body)`.
|
|
|
|
**Assertions**: Node.js built-in `assert` module in step definitions.
|
|
|
|
**Running**: `yarn workspace @perauset/e2e test` which runs `cucumber-js --config tests/cucumber.mjs`.
|
|
|
|
### Auth Approach
|
|
|
|
Uses `@pikku/auth-js` (same as makeanagent):
|
|
|
|
- **Middleware**: `authJsSession({ secretId: 'AUTH_SECRET', mapSession: (claims) => ({ userId: claims.sub }) })` added to HTTP middleware chain
|
|
- **Auth routes**: `createAuthRoutes(config)` + `wireHTTPRoutes` to expose `/auth/csrf`, `/auth/session`, `/auth/signin`, `/auth/callback/:provider`, `/auth/signout`
|
|
- **Auth wiring**: Separate `packages/functions/src/wirings/auth.wiring.ts` file (following makeanagent pattern)
|
|
- **Dev/test provider**: Credentials provider for development/testing. Real providers (Google, etc.) added later.
|
|
- **Session**: JWT-based via Auth.js cookies. `UserSession` populated by middleware on every request.
|
|
|
|
---
|
|
|
|
## Work Packages
|
|
|
|
---
|
|
|
|
### WP-01: E2E Test Infrastructure
|
|
|
|
**Dependencies**: None
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Set up the e2e test workspace with Cucumber.js, server lifecycle management, API client, and a passing health-check feature.
|
|
|
|
**Deliverables**:
|
|
- `e2e/package.json` — name: `@perauset/e2e`, deps: `@cucumber/cucumber`, `tsx`, `typescript`, `pg`, `@types/node`
|
|
- `e2e/tsconfig.json` — target ES2022, module Node18, moduleResolution node16
|
|
- `e2e/tests/cucumber.mjs` — Cucumber config: requireModule tsx, require support + steps, paths to features, format progress-bar + html report
|
|
- `e2e/tests/support/config.ts` — test API URL (`http://localhost:6099`), test DB name (`perauset_test`), timeout
|
|
- `e2e/tests/support/api-client.ts` — `apiGet(path, cookies?)`, `apiPost(path, body, cookies?)`, `apiPut(path, body, cookies?)` wrapping fetch with base URL, JSON headers, and cookie forwarding
|
|
- `e2e/tests/support/hooks.ts` — BeforeAll: spawn backend on port 6099 with `perauset_test` DB env vars, run migrations, wait for server ready. AfterAll: kill server process. Pattern follows makeanagent e2e hooks.
|
|
- `e2e/tests/support/world.ts` — Custom Cucumber World with `apiGet`/`apiPost`/`apiPut` methods, `lastResponse` state, `lastBody` state, optional session cookies
|
|
- `e2e/tests/support/db.ts` — direct pg Client connection to `perauset_test` for assertions and cleanup
|
|
- `e2e/tests/features/phase-0/health-check.feature` — `GET /health-check` returns 200 with `{ ok: true }`
|
|
- `e2e/tests/steps/phase-0/health-check.steps.ts` — step definitions for health-check feature
|
|
- Update root `package.json` to add `e2e` to workspaces and add `test:e2e` script
|
|
|
|
**Verification**:
|
|
- `yarn workspace @perauset/e2e test` starts the server, runs the health-check feature, passes, shuts down the server
|
|
- Server process does not remain running after tests complete
|
|
|
|
---
|
|
|
|
### WP-02: Shared Library - Permissions, State Machine, Errors
|
|
|
|
**Dependencies**: None
|
|
**Estimated Complexity**: S
|
|
|
|
**Description**: Create the shared library modules that all domain functions will depend on. These are pure TypeScript modules with no database dependencies yet (they accept a Kysely instance as a parameter). Note: audit logging is handled entirely by PostgreSQL triggers (see WP-04), so no application-level activity log helper is needed.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/lib/errors.ts` -- `NotFoundError`, `ForbiddenError`, `ConflictError`, `ValidationError` extending a base `DomainError` class
|
|
- `packages/functions/src/lib/state-machine.ts` -- `validateTransition(entityType, fromStatus, toStatus)` that checks against a map of allowed transitions for each entity type (`stay_requests`, `stays`, `boat_reservation_requests`, `task_instances`); throws `ConflictError` on invalid transition
|
|
- `packages/functions/src/lib/permissions.ts` -- Uses Pikku's `addPermission(tag, [checkFn])` to register tag-based permission checks. For each permission tag (e.g. `stays.review`, `rooms.manage`), registers a function that queries `user_role_assignments` + `role_permissions` + `permissions` to check if the current user has that permission key. Pikku automatically runs these checks before any function tagged with the matching tag. Supports scoped permissions (e.g. retreat-scoped facilitator roles). Will compile but not run until migration 0002.
|
|
|
|
**Verification**:
|
|
- `yarn workspace @perauset/functions tsc` passes (type-checks cleanly even though DB tables don't exist yet)
|
|
- State machine unit logic can be verified: valid transitions return void, invalid transitions throw
|
|
|
|
---
|
|
|
|
### WP-03: Migration 0002 - Roles, Permissions, User Role Assignments
|
|
|
|
**Dependencies**: WP-02
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create the RBAC schema. Roles are named records. Permissions are granular action keys. `role_permissions` maps roles to permissions. `user_role_assignments` maps users to roles with optional scope and time bounds.
|
|
|
|
**Deliverables**:
|
|
- `sql/0002-roles-permissions.sql`:
|
|
```
|
|
app.role (role_id UUID PK, name TEXT UNIQUE NOT NULL, description TEXT, is_system BOOLEAN DEFAULT false, created_at, updated_at)
|
|
app.permission (permission_id UUID PK, key TEXT UNIQUE NOT NULL, description TEXT, module TEXT NOT NULL)
|
|
app.role_permission (role_id FK, permission_id FK, PK(role_id, permission_id))
|
|
app.user_role_assignment (assignment_id UUID PK, user_id FK, role_id FK, scope_type TEXT, scope_id UUID, valid_from TIMESTAMPTZ, valid_until TIMESTAMPTZ, assigned_by UUID FK, created_at)
|
|
```
|
|
- Run `yarn workspace @perauset/functions db:types` to regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- `cd backend && yarn dbmigrate` runs without error
|
|
- `db.types.ts` contains `Role`, `Permission`, `RolePermission`, `UserRoleAssignment` interfaces
|
|
- `yarn workspace @perauset/functions tsc` passes with updated types
|
|
|
|
---
|
|
|
|
### WP-04: Migration 0003 - Audit Log (Trigger-Based)
|
|
|
|
**Dependencies**: WP-03
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create a single audit log table with a PostgreSQL trigger function that fires automatically on INSERT/UPDATE/DELETE for any tracked table. No application-level logging code needed — the database handles it. The trigger computes a JSON diff of changed fields (NEW - OLD) for updates, full row for inserts/deletes. A `app.current_user_id` session variable is set per-request so the trigger can record who made the change.
|
|
|
|
**Deliverables**:
|
|
- `sql/0003-audit-log.sql`:
|
|
```sql
|
|
-- Audit log table
|
|
CREATE TABLE app.audit_log (
|
|
audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
table_name TEXT NOT NULL,
|
|
record_id UUID NOT NULL,
|
|
action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
|
|
user_id UUID, -- from session variable, nullable for system actions
|
|
changed_fields JSONB, -- for UPDATE: only changed fields {field: {old, new}}
|
|
-- for INSERT: full new row
|
|
-- for DELETE: full old row
|
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX idx_audit_log_table_record ON app.audit_log(table_name, record_id);
|
|
CREATE INDEX idx_audit_log_user ON app.audit_log(user_id);
|
|
CREATE INDEX idx_audit_log_occurred ON app.audit_log(occurred_at);
|
|
|
|
-- Generic trigger function
|
|
CREATE OR REPLACE FUNCTION app.audit_trigger_func() RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
_user_id UUID;
|
|
_record_id UUID;
|
|
_changed JSONB;
|
|
_old JSONB;
|
|
_new JSONB;
|
|
_key TEXT;
|
|
BEGIN
|
|
-- Get current user from session variable (set per-request by app)
|
|
BEGIN
|
|
_user_id := current_setting('app.current_user_id', true)::UUID;
|
|
EXCEPTION WHEN OTHERS THEN
|
|
_user_id := NULL;
|
|
END;
|
|
|
|
IF TG_OP = 'DELETE' THEN
|
|
_record_id := (row_to_json(OLD) ->> TG_ARGV[0])::UUID;
|
|
INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields)
|
|
VALUES (TG_TABLE_NAME, _record_id, 'DELETE', _user_id, row_to_json(OLD)::JSONB);
|
|
RETURN OLD;
|
|
ELSIF TG_OP = 'INSERT' THEN
|
|
_record_id := (row_to_json(NEW) ->> TG_ARGV[0])::UUID;
|
|
INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields)
|
|
VALUES (TG_TABLE_NAME, _record_id, 'INSERT', _user_id, row_to_json(NEW)::JSONB);
|
|
RETURN NEW;
|
|
ELSIF TG_OP = 'UPDATE' THEN
|
|
_record_id := (row_to_json(NEW) ->> TG_ARGV[0])::UUID;
|
|
_old := row_to_json(OLD)::JSONB;
|
|
_new := row_to_json(NEW)::JSONB;
|
|
_changed := '{}'::JSONB;
|
|
FOR _key IN SELECT jsonb_object_keys(_new)
|
|
LOOP
|
|
IF _key = 'updated_at' THEN CONTINUE; END IF;
|
|
IF (_old ->> _key) IS DISTINCT FROM (_new ->> _key) THEN
|
|
_changed := _changed || jsonb_build_object(_key, jsonb_build_object('old', _old -> _key, 'new', _new -> _key));
|
|
END IF;
|
|
END LOOP;
|
|
-- Skip if nothing actually changed
|
|
IF _changed = '{}'::JSONB THEN RETURN NEW; END IF;
|
|
INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields)
|
|
VALUES (TG_TABLE_NAME, _record_id, 'UPDATE', _user_id, _changed);
|
|
RETURN NEW;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Helper to attach trigger to a table (called in each domain migration)
|
|
CREATE OR REPLACE FUNCTION app.enable_audit(target_table TEXT, pk_column TEXT DEFAULT 'id') RETURNS VOID AS $$
|
|
BEGIN
|
|
EXECUTE format(
|
|
'CREATE TRIGGER audit_%I AFTER INSERT OR UPDATE OR DELETE ON app.%I FOR EACH ROW EXECUTE FUNCTION app.audit_trigger_func(%L)',
|
|
target_table, target_table, pk_column
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Enable audit on existing tables
|
|
SELECT app.enable_audit('user', 'user_id');
|
|
SELECT app.enable_audit('role', 'role_id');
|
|
SELECT app.enable_audit('permission', 'permission_id');
|
|
SELECT app.enable_audit('role_permission', 'role_id'); -- composite PK, use role_id
|
|
SELECT app.enable_audit('user_role_assignment', 'assignment_id');
|
|
```
|
|
- Each subsequent domain migration calls `SELECT app.enable_audit('table_name', 'pk_column')` for its new tables
|
|
- App middleware sets `SET LOCAL app.current_user_id = '<uuid>'` per-request via Kysely connection hook
|
|
- `packages/functions/src/lib/audit-context.ts` — helper that runs `SET LOCAL app.current_user_id` on the Kysely connection so the trigger knows who made the change
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean
|
|
- `db.types.ts` contains `AuditLog` interface
|
|
- Insert/update/delete a user row → verify `app.audit_log` has corresponding entry
|
|
- Update a user's `display_name` → verify `changed_fields` contains `{"display_name": {"old": "...", "new": "..."}}` (not the full row)
|
|
- `tsc` passes
|
|
|
|
---
|
|
|
|
### WP-05: Seed Data Migration
|
|
|
|
**Dependencies**: WP-04
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Insert default roles, permissions, role-permission mappings, and an admin user. This is a SQL migration so it runs automatically.
|
|
|
|
**Deliverables**:
|
|
- `sql/0004-seed-data.sql`:
|
|
- Insert roles: `admin`, `coordinator`, `community_member`, `guest`, `facilitator`
|
|
- Insert permissions grouped by module:
|
|
- `users`: `users.view`, `users.manage`
|
|
- `roles`: `roles.assign`, `roles.manage`
|
|
- `stays`: `stays.request`, `stays.review`, `stays.manage`, `stays.view_all`
|
|
- `rooms`: `rooms.manage`, `rooms.request_preference`, `rooms.view_all`
|
|
- `boats`: `boats.manage`, `boats.request`, `boats.view_all`
|
|
- `tasks`: `tasks.manage`, `tasks.assign`, `tasks.claim`, `tasks.view_all`
|
|
- `retreats`: `retreats.manage`, `retreats.view`, `retreats.participate`
|
|
- `kitchen`: `kitchen.manage`, `kitchen.view`, `kitchen.update_dietary`
|
|
- `inventory`: `inventory.manage`, `inventory.request`
|
|
- `notifications`: `notifications.view`
|
|
- `audit_log`: `audit_log.view`
|
|
- Map permissions to roles (admin gets all, coordinator gets manage/review, community_member gets request/claim/view own)
|
|
- Insert admin user into `app.user` with known email `admin@perauset.org`
|
|
- Assign admin role to admin user
|
|
|
|
**Verification**:
|
|
- Migration runs clean
|
|
- Query `SELECT * FROM app.role` returns 5 roles
|
|
- Query `SELECT count(*) FROM app.permission` returns expected count
|
|
- Query `SELECT count(*) FROM app.role_permission` returns expected mappings
|
|
- Admin user exists and has admin role assignment
|
|
|
|
---
|
|
|
|
### WP-06: Auth - @pikku/auth-js Integration
|
|
|
|
**Dependencies**: WP-05
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Integrate `@pikku/auth-js` for session management. Set up Auth.js routes, session middleware, and a credentials provider for dev/test. Follow the makeanagent pattern.
|
|
|
|
**Deliverables**:
|
|
- Add `@pikku/auth-js` and `@auth/core` to `packages/functions/package.json` dependencies
|
|
- `packages/functions/src/wirings/auth.wiring.ts` — creates Auth.js routes with credentials provider (email/password lookup against `app.user`), wires them via `createAuthRoutes` + `wireHTTPRoutes` at `/auth` basePath
|
|
- Update `packages/functions/src/middleware.ts` — add `authJsSession({ secretId: 'AUTH_SECRET', mapSession: (claims) => ({ userId: claims.sub as string }) })` to the middleware chain
|
|
- Update `backend/bin/start.ts` — import the auth wiring file (`@perauset/functions/src/wirings/auth.wiring.js`)
|
|
- Update `e2e/tests/support/world.ts` — add `login()` method that calls `/auth/csrf` then `/auth/callback/credentials` to get session cookies (same flow as makeanagent e2e world.ts)
|
|
- `e2e/tests/features/phase-0/auth.feature` — scenarios: unauthenticated request returns 401, authenticated session returns user info
|
|
- `e2e/tests/steps/phase-0/auth.steps.ts` — step definitions
|
|
|
|
**Verification**:
|
|
- E2E: `GET /auth/session` without cookies returns no session / 401
|
|
- E2E: After login via credentials flow, `GET /auth/session` returns session with userId
|
|
- `e2e/tests/features/phase-0/auth.feature` passes
|
|
|
|
---
|
|
|
|
### WP-07: User Management Functions
|
|
|
|
**Dependencies**: WP-06
|
|
**Estimated Complexity**: S
|
|
|
|
**Description**: Basic user CRUD functions (not generic patch -- specific commands).
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/users/get-user.function.ts` -- GET /users/:userId, requires `users.view` permission or own user
|
|
- `packages/functions/src/functions/users/update-user-profile.function.ts` -- PUT /users/:userId/profile, updates first_name, last_name, display_name, phone. Requires own user or `users.manage`.
|
|
- `packages/functions/src/functions/users/list-users.function.ts` -- GET /users, requires `users.view` permission, returns paginated list
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-0/users.feature`
|
|
- Get admin user by ID returns correct data
|
|
- Update own profile succeeds
|
|
- List users returns at least the admin user
|
|
- Unauthenticated request returns 401
|
|
- User without `users.view` tag cannot list users (403)
|
|
- User without `users.manage` tag cannot update another user's profile (403)
|
|
|
|
---
|
|
|
|
### WP-08: Role Assignment Functions
|
|
|
|
**Dependencies**: WP-07
|
|
**Estimated Complexity**: S
|
|
|
|
**Description**: Functions to assign and revoke roles from users.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/roles/assign-role.function.ts` -- POST /roles/assign, body: `{ userId, roleId, scopeType?, scopeId?, validFrom?, validUntil? }`. Tagged `roles.assign`.
|
|
- `packages/functions/src/functions/roles/revoke-role.function.ts` -- POST /roles/revoke, body: `{ assignmentId }`. Tagged `roles.assign`.
|
|
- `packages/functions/src/functions/roles/list-roles.function.ts` -- GET /roles, returns all roles
|
|
- `packages/functions/src/functions/roles/list-user-roles.function.ts` -- GET /users/:userId/roles, returns role assignments for a user
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-0/roles-permissions.feature`
|
|
- List roles returns seed data roles
|
|
- Admin can assign community_member role to a new user
|
|
- List that user's roles shows the assignment
|
|
- Revoke the assignment, list shows empty
|
|
- User without `roles.assign` tag cannot assign roles (403)
|
|
- User without `roles.assign` tag cannot revoke roles (403)
|
|
|
|
---
|
|
|
|
### WP-09: Audit Log Query Function
|
|
|
|
**Dependencies**: WP-08
|
|
**Estimated Complexity**: S
|
|
|
|
**Description**: Read-only endpoint to query the trigger-based audit log. Since all mutations are automatically captured by PostgreSQL triggers, this just needs a query API.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/audit/list-audit-log.function.ts` -- GET /audit-log, query params: `tableName`, `recordId`, `userId`, `action`, `limit`, `offset`. Tagged `audit_log.view`.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-0/audit-log.feature`
|
|
- After performing a user profile update (from WP-07), query audit log for that table/record
|
|
- Verify the entry exists with action `UPDATE`, correct user_id, and `changed_fields` showing the diff
|
|
- User without `audit_log.view` tag cannot query audit logs (403)
|
|
|
|
---
|
|
|
|
### WP-10: Migration 0005 - Stay Requests and Stays
|
|
|
|
**Dependencies**: WP-09 (Phase 0 complete)
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create the presence/stay tables with proper separation of request and confirmed state.
|
|
|
|
**Deliverables**:
|
|
- `sql/0005-stays.sql`:
|
|
```
|
|
app.stay_request (
|
|
request_id UUID PK, user_id UUID FK, requested_arrival DATE NOT NULL, requested_departure DATE NOT NULL,
|
|
purpose TEXT, notes TEXT, stay_type TEXT NOT NULL DEFAULT 'general',
|
|
status TEXT NOT NULL DEFAULT 'submitted', -- submitted, under_review, approved, rejected, cancelled
|
|
reviewed_by UUID FK, review_notes TEXT, reviewed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.stay (
|
|
stay_id UUID PK, user_id UUID FK, request_id UUID FK REFERENCES app.stay_request,
|
|
arrival_date DATE NOT NULL, departure_date DATE NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, confirmed, checked_in, checked_out, cancelled
|
|
stay_type TEXT NOT NULL DEFAULT 'general',
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean
|
|
- `db.types.ts` contains `StayRequest` and `Stay` interfaces
|
|
- `tsc` passes
|
|
|
|
---
|
|
|
|
### WP-11: Stay Request Functions
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: L
|
|
|
|
**Description**: Implement the full stay request lifecycle: submit, review, approve, reject, cancel.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/stays/request-stay.function.ts` -- POST /stays/requests, body: `{ requestedArrival, requestedDeparture, purpose?, notes?, stayType? }`. Tagged `stays.request`. Creates stay_request with status `submitted`.
|
|
- `packages/functions/src/functions/stays/review-stay-request.function.ts` -- POST /stays/requests/:requestId/review, body: `{ reviewNotes? }`. Tagged `stays.review`. Transitions status `submitted -> under_review`.
|
|
- `packages/functions/src/functions/stays/approve-stay-request.function.ts` -- POST /stays/requests/:requestId/approve, body: `{ reviewNotes? }`. Tagged `stays.review`. Transitions `submitted|under_review -> approved`.
|
|
- `packages/functions/src/functions/stays/reject-stay-request.function.ts` -- POST /stays/requests/:requestId/reject, body: `{ reviewNotes }`. Tagged `stays.review`. Transitions `submitted|under_review -> rejected`.
|
|
- `packages/functions/src/functions/stays/list-stay-requests.function.ts` -- GET /stays/requests, query params: `status`, `userId`. Tagged `stays.view_all` for others' requests, own requests always visible.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-1/stays.feature` (request lifecycle)
|
|
- Submit a stay request, verify status is `submitted`
|
|
- Review it, verify status is `under_review`
|
|
- Approve it, verify status is `approved`
|
|
- Submit another, reject it
|
|
- Submit another, cancel it
|
|
- Attempt invalid transition (rejected -> approved), verify error
|
|
- Verify audit log entries exist for each state change (automatic via trigger)
|
|
- User without `stays.request` tag cannot submit a stay request (403)
|
|
- User without `stays.review` tag cannot review/approve/reject (403)
|
|
- User can view own requests but not others' without `stays.view_all` (403)
|
|
|
|
---
|
|
|
|
### WP-12: Stay Lifecycle Functions
|
|
|
|
**Dependencies**: WP-11
|
|
**Estimated Complexity**: L
|
|
|
|
**Description**: Implement confirmed stay lifecycle: create from request, check in, check out, extend, cancel, override dates.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/stays/create-stay-from-request.function.ts` -- POST /stays, body: `{ requestId, arrivalDate?, departureDate? }`. Tagged `stays.manage`. Request must be `approved`. Creates stay with status `pending`.
|
|
- `packages/functions/src/functions/stays/check-in-stay.function.ts` -- POST /stays/:stayId/check-in. Tagged `stays.manage`. Transitions `confirmed -> checked_in`.
|
|
- `packages/functions/src/functions/stays/check-out-stay.function.ts` -- POST /stays/:stayId/check-out. Tagged `stays.manage`. Transitions `checked_in -> checked_out`.
|
|
- `packages/functions/src/functions/stays/extend-stay.function.ts` -- POST /stays/:stayId/extend, body: `{ newDepartureDate }`. Tagged `stays.manage`. Only valid when `confirmed` or `checked_in`.
|
|
- `packages/functions/src/functions/stays/cancel-stay.function.ts` -- POST /stays/:stayId/cancel, body: `{ reason? }`. Tagged `stays.manage` or own stay. Transitions any non-terminal -> `cancelled`.
|
|
- `packages/functions/src/functions/stays/override-stay-dates.function.ts` -- POST /stays/:stayId/override-dates, body: `{ arrivalDate?, departureDate?, reason }`. Tagged `stays.manage`. Admin override (audit trigger captures the diff automatically).
|
|
- `packages/functions/src/functions/stays/list-stays.function.ts` -- GET /stays, query params: `status`, `userId`, `from`, `to`. Tagged `stays.view_all` for all, own stays always visible.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-1/stays.feature` (stay lifecycle)
|
|
- Create stay from approved request
|
|
- Confirm stay (pending -> confirmed)
|
|
- Check in, check out
|
|
- Verify full lifecycle transitions
|
|
- Test extend while checked in
|
|
- Test cancel
|
|
- Test override dates with reason
|
|
- Verify audit log entries exist for each state change (automatic via trigger)
|
|
- User without `stays.manage` tag cannot create/check-in/check-out/extend/override stays (403)
|
|
- User can cancel own stay but not others' without `stays.manage` (403)
|
|
|
|
---
|
|
|
|
### WP-13: Migration 0006 - Rooms
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create room-related tables: rooms, room blocks, room requests (preferences), room allocations.
|
|
|
|
**Deliverables**:
|
|
- `sql/0006-rooms.sql`:
|
|
```
|
|
app.room (
|
|
room_id UUID PK, name TEXT NOT NULL UNIQUE, building TEXT, floor INT,
|
|
capacity INT NOT NULL DEFAULT 1, room_type TEXT NOT NULL DEFAULT 'standard',
|
|
amenities JSONB DEFAULT '[]', status TEXT NOT NULL DEFAULT 'available',
|
|
notes TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.room_block (
|
|
block_id UUID PK, room_id UUID FK, start_date DATE NOT NULL, end_date DATE NOT NULL,
|
|
reason TEXT NOT NULL, blocked_by UUID FK,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.room_request (
|
|
request_id UUID PK, stay_id UUID FK, room_type_pref TEXT, room_id_pref UUID FK,
|
|
notes TEXT, status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.room_allocation (
|
|
allocation_id UUID PK, room_id UUID FK, stay_id UUID FK, request_id UUID FK,
|
|
start_date DATE NOT NULL, end_date DATE NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active', -- active, released, reallocated, cancelled
|
|
allocated_by UUID FK, notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
```
|
|
- Add seed rooms to `0004-seed-data.sql` or create `sql/0006b-seed-rooms.sql` (actually, better to include room seeds in the same migration as a separate statement block)
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean
|
|
- Types generated correctly
|
|
|
|
---
|
|
|
|
### WP-14: Room Functions
|
|
|
|
**Dependencies**: WP-13, WP-12
|
|
**Estimated Complexity**: L
|
|
|
|
**Description**: All room management and allocation functions.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/rooms/create-room.function.ts` -- POST /rooms. Tagged `rooms.manage`.
|
|
- `packages/functions/src/functions/rooms/block-room.function.ts` -- POST /rooms/:roomId/block. Tagged `rooms.manage`. Checks no conflicting allocations.
|
|
- `packages/functions/src/functions/rooms/request-room-preference.function.ts` -- POST /rooms/requests, body: `{ stayId, roomTypePref?, roomIdPref?, notes? }`. Tagged `rooms.request_preference` or own stay.
|
|
- `packages/functions/src/functions/rooms/allocate-room.function.ts` -- POST /rooms/allocations, body: `{ roomId, stayId, requestId?, startDate, endDate }`. Tagged `rooms.manage`. Checks room availability (no overlapping active allocations, no blocks).
|
|
- `packages/functions/src/functions/rooms/reallocate-room.function.ts` -- POST /rooms/allocations/:allocationId/reallocate, body: `{ newRoomId, reason }`. Tagged `rooms.manage`. Releases old, creates new.
|
|
- `packages/functions/src/functions/rooms/release-room.function.ts` -- POST /rooms/allocations/:allocationId/release. Tagged `rooms.manage`. Sets status to `released`.
|
|
- `packages/functions/src/functions/rooms/cancel-room-allocation.function.ts` -- POST /rooms/allocations/:allocationId/cancel. Tagged `rooms.manage`.
|
|
- `packages/functions/src/functions/rooms/list-rooms.function.ts` -- GET /rooms, includes availability info for date range. Tagged `rooms.view_all`.
|
|
- `packages/functions/src/functions/rooms/list-room-allocations.function.ts` -- GET /rooms/allocations. Tagged `rooms.view_all`.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-1/rooms.feature`
|
|
- Create a room
|
|
- Create a stay, allocate room to stay
|
|
- Attempt double-allocation for overlapping dates, verify conflict error
|
|
- Block a room, attempt allocation during block, verify error
|
|
- Reallocate to different room
|
|
- Release room
|
|
- User without `rooms.manage` tag cannot create/allocate/block/release rooms (403)
|
|
- User can request room preference for own stay but not without `rooms.request_preference` (403)
|
|
- User without `rooms.view_all` tag cannot list all allocations (403)
|
|
|
|
---
|
|
|
|
### WP-15: Migration 0007 - Boats
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create boat transport tables.
|
|
|
|
**Deliverables**:
|
|
- `sql/0007-boats.sql`:
|
|
```
|
|
app.boat_route (
|
|
route_id UUID PK, name TEXT NOT NULL, origin TEXT NOT NULL, destination TEXT NOT NULL,
|
|
typical_duration_minutes INT, notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.boat (
|
|
boat_id UUID PK, name TEXT NOT NULL, capacity INT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active', notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.boat_trip (
|
|
trip_id UUID PK, route_id UUID FK, boat_id UUID FK,
|
|
departure_time TIMESTAMPTZ NOT NULL, arrival_time TIMESTAMPTZ,
|
|
status TEXT NOT NULL DEFAULT 'planned', -- planned, open_for_requests, boarding, in_transit, completed, cancelled
|
|
available_seats INT NOT NULL,
|
|
notes TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.boat_reservation_request (
|
|
request_id UUID PK, trip_id UUID FK, user_id UUID FK,
|
|
passenger_count INT NOT NULL DEFAULT 1, luggage_notes TEXT,
|
|
status TEXT NOT NULL DEFAULT 'submitted', -- submitted, under_review, confirmed, waitlisted, declined, cancelled
|
|
reviewed_by UUID FK, review_notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.boat_manifest_entry (
|
|
entry_id UUID PK, trip_id UUID FK, user_id UUID FK, request_id UUID FK,
|
|
seat_number INT, boarded BOOLEAN DEFAULT false, boarded_at TIMESTAMPTZ,
|
|
notes TEXT, created_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean, types regenerated
|
|
|
|
---
|
|
|
|
### WP-16: Boat Functions
|
|
|
|
**Dependencies**: WP-15
|
|
**Estimated Complexity**: L
|
|
|
|
**Description**: Full boat transport request and manifest lifecycle.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/boats/create-boat-route.function.ts` -- POST /boats/routes. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/create-boat.function.ts` -- POST /boats. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/create-boat-trip.function.ts` -- POST /boats/trips. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/open-boat-trip.function.ts` -- POST /boats/trips/:tripId/open. Tagged `boats.manage`. Transitions `planned -> open_for_requests`.
|
|
- `packages/functions/src/functions/boats/request-boat-seat.function.ts` -- POST /boats/trips/:tripId/request-seat. Tagged `boats.request`. Checks trip is `open_for_requests`.
|
|
- `packages/functions/src/functions/boats/review-boat-request.function.ts` -- POST /boats/requests/:requestId/review. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/confirm-boat-request.function.ts` -- POST /boats/requests/:requestId/confirm. Tagged `boats.manage`. Checks capacity. Creates manifest entry.
|
|
- `packages/functions/src/functions/boats/waitlist-boat-request.function.ts` -- POST /boats/requests/:requestId/waitlist. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/decline-boat-request.function.ts` -- POST /boats/requests/:requestId/decline. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/cancel-boat-request.function.ts` -- POST /boats/requests/:requestId/cancel. Own request or `boats.manage`.
|
|
- `packages/functions/src/functions/boats/board-manifest-entry.function.ts` -- POST /boats/manifest/:entryId/board. Tagged `boats.manage`. Sets `boarded = true`.
|
|
- `packages/functions/src/functions/boats/complete-boat-trip.function.ts` -- POST /boats/trips/:tripId/complete. Tagged `boats.manage`.
|
|
- `packages/functions/src/functions/boats/list-boat-trips.function.ts` -- GET /boats/trips. Tagged `boats.view_all`.
|
|
- `packages/functions/src/functions/boats/list-boat-requests.function.ts` -- GET /boats/requests. Own or `boats.view_all`.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-1/boats.feature`
|
|
- Create route, boat, trip
|
|
- Open trip for requests
|
|
- Submit seat request
|
|
- Confirm request, verify manifest entry created
|
|
- Attempt over-capacity booking, verify error
|
|
- Board passenger, complete trip
|
|
- Test waitlist and decline flows
|
|
- User without `boats.manage` tag cannot create routes/boats/trips or confirm/decline requests (403)
|
|
- User without `boats.request` tag cannot request a seat (403)
|
|
- User can cancel own request but not others' without `boats.manage` (403)
|
|
|
|
---
|
|
|
|
### WP-17: Migration 0008 - Tasks
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create task management tables.
|
|
|
|
**Deliverables**:
|
|
- `sql/0008-tasks.sql`:
|
|
```
|
|
app.task_template (
|
|
template_id UUID PK, title TEXT NOT NULL, description TEXT,
|
|
category TEXT NOT NULL, estimated_minutes INT,
|
|
recurrence_rule TEXT, -- e.g. 'daily', 'weekly:mon,wed,fri', 'monthly:1,15'
|
|
default_assignee_role TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.task_instance (
|
|
task_id UUID PK, template_id UUID FK,
|
|
title TEXT NOT NULL, description TEXT, category TEXT NOT NULL,
|
|
due_date DATE, due_time TIME,
|
|
status TEXT NOT NULL DEFAULT 'planned', -- planned, open, assigned, in_progress, completed, blocked, cancelled
|
|
priority TEXT NOT NULL DEFAULT 'normal', -- low, normal, high, urgent
|
|
estimated_minutes INT, actual_minutes INT,
|
|
notes TEXT, completed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.task_assignment (
|
|
assignment_id UUID PK, task_id UUID FK, user_id UUID FK,
|
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined
|
|
assigned_by UUID FK, assigned_at TIMESTAMPTZ DEFAULT now(),
|
|
responded_at TIMESTAMPTZ
|
|
)
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean, types regenerated
|
|
|
|
---
|
|
|
|
### WP-18: Task Functions
|
|
|
|
**Dependencies**: WP-17
|
|
**Estimated Complexity**: L
|
|
|
|
**Description**: Full task lifecycle functions.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/tasks/create-task-template.function.ts` -- POST /tasks/templates. Tagged `tasks.manage`.
|
|
- `packages/functions/src/functions/tasks/generate-recurring-tasks.function.ts` -- POST /tasks/generate, body: `{ templateId, fromDate, toDate }`. Tagged `tasks.manage`. Creates individual task_instance rows for each occurrence (not a magical repeating row).
|
|
- `packages/functions/src/functions/tasks/create-task-instance.function.ts` -- POST /tasks. Tagged `tasks.manage`.
|
|
- `packages/functions/src/functions/tasks/assign-task.function.ts` -- POST /tasks/:taskId/assign, body: `{ userId }`. Tagged `tasks.assign`. Creates assignment, transitions task `planned|open -> assigned`.
|
|
- `packages/functions/src/functions/tasks/claim-task.function.ts` -- POST /tasks/:taskId/claim. Tagged `tasks.claim`. Self-assignment for `open` tasks.
|
|
- `packages/functions/src/functions/tasks/accept-task-assignment.function.ts` -- POST /tasks/assignments/:assignmentId/accept. Own assignment only.
|
|
- `packages/functions/src/functions/tasks/decline-task-assignment.function.ts` -- POST /tasks/assignments/:assignmentId/decline. Own assignment only. Task goes back to `open` if no other accepted assignments.
|
|
- `packages/functions/src/functions/tasks/start-task.function.ts` -- POST /tasks/:taskId/start. Assigned user. Transitions `assigned -> in_progress`.
|
|
- `packages/functions/src/functions/tasks/complete-task.function.ts` -- POST /tasks/:taskId/complete, body: `{ actualMinutes?, notes? }`. Transitions `in_progress -> completed`.
|
|
- `packages/functions/src/functions/tasks/mark-task-blocked.function.ts` -- POST /tasks/:taskId/block, body: `{ reason }`. Transitions `assigned|in_progress -> blocked`.
|
|
- `packages/functions/src/functions/tasks/reassign-task.function.ts` -- POST /tasks/:taskId/reassign, body: `{ userId }`. Tagged `tasks.assign`.
|
|
- `packages/functions/src/functions/tasks/list-tasks.function.ts` -- GET /tasks. Tagged `tasks.view_all` for all, own assigned tasks always visible.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-1/tasks.feature`
|
|
- Create template, generate recurring tasks for a week
|
|
- Verify correct number of task instances created
|
|
- Assign task, accept assignment, start, complete
|
|
- Test claim flow
|
|
- Test block and unblock (blocked -> in_progress)
|
|
- Test decline and reassign
|
|
- Invalid transitions rejected
|
|
- User without `tasks.manage` tag cannot create templates or generate tasks (403)
|
|
- User without `tasks.assign` tag cannot assign tasks (403)
|
|
- User without `tasks.claim` tag cannot claim open tasks (403)
|
|
- User can only accept/decline own assignments
|
|
|
|
---
|
|
|
|
### WP-19: Migration 0009 - Notifications
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: S
|
|
|
|
**Description**: Create notifications table.
|
|
|
|
**Deliverables**:
|
|
- `sql/0009-notifications.sql`:
|
|
```
|
|
app.notification (
|
|
notification_id UUID PK, user_id UUID FK,
|
|
type TEXT NOT NULL, -- e.g. 'stay_approved', 'task_assigned', 'boat_confirmed'
|
|
title TEXT NOT NULL, body TEXT,
|
|
entity_type TEXT, entity_id UUID,
|
|
read BOOLEAN DEFAULT false, read_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
CREATE INDEX idx_notification_user ON app.notification(user_id, read, created_at DESC);
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean, types regenerated
|
|
|
|
---
|
|
|
|
### WP-20: Notification Functions + Integration
|
|
|
|
**Dependencies**: WP-19
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Notification query endpoints plus a shared helper that domain functions can call to create notifications.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/lib/notifications.ts` -- `createNotification(kysely, { userId, type, title, body?, entityType?, entityId? })` helper
|
|
- `packages/functions/src/functions/notifications/list-notifications.function.ts` -- GET /notifications. Own notifications. Query params: `read`, `limit`, `offset`.
|
|
- `packages/functions/src/functions/notifications/mark-notification-read.function.ts` -- POST /notifications/:notificationId/read. Own notification only.
|
|
- Update key domain functions to call `createNotification`:
|
|
- `approve-stay-request` notifies the requester
|
|
- `reject-stay-request` notifies the requester
|
|
- `assign-task` notifies the assignee
|
|
- `confirm-boat-request` notifies the requester
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-1/notifications.feature`
|
|
- Approve a stay request, verify notification created for requester
|
|
- List notifications, verify it appears
|
|
- Mark as read, verify it's marked
|
|
- User can only see and mark own notifications
|
|
- Cannot mark another user's notification as read (403)
|
|
|
|
---
|
|
|
|
### WP-21: Migration 0010 - Retreats
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create retreat-related tables.
|
|
|
|
**Deliverables**:
|
|
- `sql/0010-retreats.sql`:
|
|
```
|
|
app.retreat (
|
|
retreat_id UUID PK, title TEXT NOT NULL, description TEXT,
|
|
start_date DATE NOT NULL, end_date DATE NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'draft', -- draft, published, in_progress, completed, cancelled
|
|
max_participants INT, location TEXT,
|
|
created_by UUID FK, published_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.retreat_person (
|
|
retreat_person_id UUID PK, retreat_id UUID FK, user_id UUID FK,
|
|
role TEXT NOT NULL DEFAULT 'participant', -- participant, facilitator, organizer, support
|
|
stay_id UUID FK, -- optional link to a stay
|
|
status TEXT NOT NULL DEFAULT 'registered', -- registered, confirmed, cancelled, no_show
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(),
|
|
UNIQUE(retreat_id, user_id)
|
|
)
|
|
app.retreat_schedule_item (
|
|
item_id UUID PK, retreat_id UUID FK,
|
|
title TEXT NOT NULL, description TEXT,
|
|
start_time TIMESTAMPTZ NOT NULL, end_time TIMESTAMPTZ NOT NULL,
|
|
location TEXT, facilitator_id UUID FK,
|
|
item_type TEXT NOT NULL DEFAULT 'session', -- session, meal, free_time, ceremony, other
|
|
sort_order INT DEFAULT 0,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean, types regenerated
|
|
|
|
---
|
|
|
|
### WP-22: Retreat Functions
|
|
|
|
**Dependencies**: WP-21, WP-12
|
|
**Estimated Complexity**: L
|
|
|
|
**Description**: Full retreat management functions.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/retreats/create-retreat.function.ts` -- POST /retreats. Tagged `retreats.manage`.
|
|
- `packages/functions/src/functions/retreats/update-retreat.function.ts` -- PUT /retreats/:retreatId. Tagged `retreats.manage`. Only `draft` retreats editable.
|
|
- `packages/functions/src/functions/retreats/add-retreat-person.function.ts` -- POST /retreats/:retreatId/people. Tagged `retreats.manage`. Body: `{ userId, role }`.
|
|
- `packages/functions/src/functions/retreats/update-retreat-person-role.function.ts` -- PUT /retreats/:retreatId/people/:retreatPersonId. Tagged `retreats.manage`.
|
|
- `packages/functions/src/functions/retreats/link-stay-to-retreat-person.function.ts` -- POST /retreats/:retreatId/people/:retreatPersonId/link-stay. Tagged `retreats.manage`. Body: `{ stayId }`.
|
|
- `packages/functions/src/functions/retreats/publish-retreat.function.ts` -- POST /retreats/:retreatId/publish. Tagged `retreats.manage`. Transitions `draft -> published`.
|
|
- `packages/functions/src/functions/retreats/add-retreat-schedule-item.function.ts` -- POST /retreats/:retreatId/schedule. Tagged `retreats.manage`.
|
|
- `packages/functions/src/functions/retreats/update-retreat-schedule-item.function.ts` -- PUT /retreats/:retreatId/schedule/:itemId. Tagged `retreats.manage`.
|
|
- `packages/functions/src/functions/retreats/cancel-retreat.function.ts` -- POST /retreats/:retreatId/cancel. Tagged `retreats.manage`. Any non-terminal -> `cancelled`.
|
|
- `packages/functions/src/functions/retreats/list-retreats.function.ts` -- GET /retreats. Published retreats visible to all authenticated users. Draft visible to `retreats.manage`.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-2/retreats.feature`
|
|
- Create retreat, add people with different roles
|
|
- Add schedule items
|
|
- Publish retreat
|
|
- Link stay to retreat person
|
|
- Cancel retreat
|
|
- Verify retreat people are NOT a subtype of guest (separate table, separate lifecycle)
|
|
- User without `retreats.manage` tag cannot create/update/publish/cancel retreats (403)
|
|
- Published retreats visible to authenticated users with `retreats.view`, drafts only to `retreats.manage`
|
|
|
|
---
|
|
|
|
### WP-23: Migration 0011 - Kitchen and Dietary
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Create kitchen/dietary tables.
|
|
|
|
**Deliverables**:
|
|
- `sql/0011-kitchen-dietary.sql`:
|
|
```
|
|
app.dietary_profile (
|
|
profile_id UUID PK, user_id UUID FK UNIQUE,
|
|
dietary_type TEXT NOT NULL DEFAULT 'omnivore', -- omnivore, vegetarian, vegan, pescatarian, other
|
|
allergies TEXT[], intolerances TEXT[],
|
|
preferences TEXT, notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.meal_service (
|
|
service_id UUID PK, date DATE NOT NULL, meal_type TEXT NOT NULL, -- breakfast, lunch, dinner, snack
|
|
expected_count INT, actual_count INT,
|
|
menu_description TEXT, notes TEXT,
|
|
status TEXT NOT NULL DEFAULT 'planned', -- planned, prepared, served, cancelled
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(),
|
|
UNIQUE(date, meal_type)
|
|
)
|
|
app.meal_attendance (
|
|
attendance_id UUID PK, service_id UUID FK, user_id UUID FK,
|
|
status TEXT NOT NULL DEFAULT 'expected', -- expected, confirmed, declined, attended
|
|
dietary_notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
UNIQUE(service_id, user_id)
|
|
)
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean, types regenerated
|
|
|
|
---
|
|
|
|
### WP-24: Kitchen Functions
|
|
|
|
**Dependencies**: WP-23
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Kitchen and dietary profile functions.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/kitchen/upsert-dietary-profile.function.ts` -- PUT /kitchen/dietary-profile. Own profile or `kitchen.manage`. Upserts by user_id.
|
|
- `packages/functions/src/functions/kitchen/create-meal-service.function.ts` -- POST /kitchen/meals. Tagged `kitchen.manage`.
|
|
- `packages/functions/src/functions/kitchen/estimate-meal-attendance.function.ts` -- POST /kitchen/meals/:serviceId/estimate. Tagged `kitchen.manage`. Auto-populates from checked-in stays for that date.
|
|
- `packages/functions/src/functions/kitchen/adjust-meal-attendance.function.ts` -- PUT /kitchen/meals/:serviceId/attendance/:userId. Own attendance or `kitchen.manage`.
|
|
- `packages/functions/src/functions/kitchen/list-meal-services.function.ts` -- GET /kitchen/meals. Query params: `date`, `mealType`. Tagged `kitchen.view`.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-2/kitchen.feature`
|
|
- Create dietary profile for user
|
|
- Create meal service
|
|
- Estimate attendance (should pick up checked-in stays)
|
|
- Adjust individual attendance
|
|
- List meals for a date
|
|
- User can update own dietary profile but not others' without `kitchen.manage` (403)
|
|
- User without `kitchen.manage` tag cannot create meal services or estimate attendance (403)
|
|
- User without `kitchen.view` tag cannot list meal services (403)
|
|
|
|
---
|
|
|
|
### WP-25: Migration 0012 - Inventory
|
|
|
|
**Dependencies**: WP-10
|
|
**Estimated Complexity**: S
|
|
|
|
**Description**: Create inventory tables.
|
|
|
|
**Deliverables**:
|
|
- `sql/0012-inventory.sql`:
|
|
```
|
|
app.inventory_item (
|
|
item_id UUID PK, name TEXT NOT NULL, category TEXT NOT NULL,
|
|
unit TEXT NOT NULL DEFAULT 'unit', -- unit, kg, liter, etc.
|
|
current_quantity NUMERIC DEFAULT 0, min_quantity NUMERIC DEFAULT 0,
|
|
location TEXT, notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
app.inventory_request (
|
|
request_id UUID PK, item_id UUID FK, requested_by UUID FK,
|
|
quantity NUMERIC NOT NULL, reason TEXT,
|
|
status TEXT NOT NULL DEFAULT 'submitted', -- submitted, approved, fulfilled, rejected, cancelled
|
|
reviewed_by UUID FK, review_notes TEXT,
|
|
fulfilled_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
```
|
|
- Regenerate `db.types.ts`
|
|
|
|
**Verification**:
|
|
- Migration runs clean, types regenerated
|
|
|
|
---
|
|
|
|
### WP-26: Inventory Functions
|
|
|
|
**Dependencies**: WP-25
|
|
**Estimated Complexity**: M
|
|
|
|
**Description**: Inventory request lifecycle.
|
|
|
|
**Deliverables**:
|
|
- `packages/functions/src/functions/inventory/request-inventory.function.ts` -- POST /inventory/requests. Tagged `inventory.request`.
|
|
- `packages/functions/src/functions/inventory/review-inventory-request.function.ts` -- POST /inventory/requests/:requestId/review. Tagged `inventory.manage`.
|
|
- `packages/functions/src/functions/inventory/fulfill-inventory-request.function.ts` -- POST /inventory/requests/:requestId/fulfill. Tagged `inventory.manage`. Updates `current_quantity` on the item.
|
|
- `packages/functions/src/functions/inventory/list-inventory.function.ts` -- GET /inventory. Tagged `inventory.manage`. Includes low-stock flag.
|
|
|
|
**Verification**:
|
|
- E2E: `e2e/tests/features/phase-2/inventory.feature`
|
|
- Create inventory items (via direct DB or admin endpoint)
|
|
- Request inventory
|
|
- Review and fulfill
|
|
- Verify quantity updated
|
|
- Verify low-stock detection
|
|
- User without `inventory.request` tag cannot submit inventory requests (403)
|
|
- User without `inventory.manage` tag cannot review/fulfill requests or list inventory (403)
|
|
|
|
---
|
|
|
|
## Dependency Graph (Execution Order)
|
|
|
|
```
|
|
WP-01 (e2e infra) WP-02 (shared libs)
|
|
| |
|
|
v v
|
|
WP-03 (roles migration) <------+
|
|
|
|
|
v
|
|
WP-04 (audit log + triggers)
|
|
|
|
|
v
|
|
WP-05 (seed data)
|
|
|
|
|
v
|
|
WP-06 (auth/session)
|
|
|
|
|
v
|
|
WP-07 (user functions)
|
|
|
|
|
v
|
|
WP-08 (role functions)
|
|
|
|
|
v
|
|
WP-09 (audit log query) -----> Phase 0 Complete
|
|
|
|
|
v
|
|
WP-10 (stays migration) -----+------+------+------+
|
|
| | | | |
|
|
v v v v v
|
|
WP-11 (stay requests) WP-13 WP-15 WP-17 WP-19
|
|
| (rooms) (boats) (tasks)(notifs)
|
|
v | | | |
|
|
WP-12 (stay lifecycle) v v v v
|
|
| WP-14 WP-16 WP-18 WP-20
|
|
| (room (boat (task (notif
|
|
| funcs) funcs) funcs) funcs)
|
|
| |
|
|
+------------------------------------+----> Phase 1 Complete
|
|
|
|
|
v
|
|
WP-21 (retreats migration) ----+------+
|
|
| | |
|
|
v v v
|
|
WP-22 (retreat funcs) WP-23 WP-25
|
|
(kitchen)(inventory)
|
|
| |
|
|
v v
|
|
WP-24 WP-26
|
|
(kitchen(inventory
|
|
funcs) funcs)
|
|
| |
|
|
+------+----> Phase 2 Complete
|
|
```
|
|
|
|
## Parallelization Notes
|
|
|
|
Within Phase 1, the following can be developed in parallel after WP-10:
|
|
- WP-11/WP-12 (stays)
|
|
- WP-13/WP-14 (rooms)
|
|
- WP-15/WP-16 (boats)
|
|
- WP-17/WP-18 (tasks)
|
|
- WP-19/WP-20 (notifications)
|
|
|
|
Within Phase 2, after WP-21:
|
|
- WP-22 (retreats), WP-23/WP-24 (kitchen), WP-25/WP-26 (inventory) can proceed in parallel.
|
|
|
|
WP-01 and WP-02 have no dependencies and can be done in parallel from day one.
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Phase | Work Packages | Est. Total Complexity |
|
|
|-------|--------------|----------------------|
|
|
| Phase 0 | WP-01 through WP-09 | 3S + 5M + 1L = ~9 units |
|
|
| Phase 1 | WP-10 through WP-20 | 2S + 4M + 5L = ~11 units |
|
|
| Phase 2 | WP-21 through WP-26 | 1S + 4M + 1L = ~6 units |
|
|
| **Total** | **26 work packages** | **~26 units** |
|