From 49d05dfa0b52e747c29c3d4b4dc4540e01045a2d Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 11 Jul 2026 08:54:43 +0200 Subject: [PATCH] chore: perauset customer project --- .env.example | 7 + .gitignore | 49 + CLAUDE.md | 42 + PLATFORM-REVIEW.md | 260 +++ README.md | 1 + .../tasks/perauset-implementation-plan.md | 1249 ++++++++++++++ ai/memory-bank/ux-architecture.md | 1117 ++++++++++++ apps/app/messages/de.json | 27 + apps/app/messages/en.json | 612 +++++++ apps/app/package.json | 40 + apps/app/project.inlang/settings.json | 11 + apps/app/src/components/ai-chat.tsx | 305 ++++ .../src/components/create-template-form.tsx | 205 +++ apps/app/src/components/locale-switcher.tsx | 30 + apps/app/src/components/notification-bell.tsx | 282 +++ apps/app/src/components/notification-row.tsx | 140 ++ apps/app/src/components/stays-tabs.tsx | 419 +++++ apps/app/src/components/task-actions.tsx | 208 +++ apps/app/src/components/user-select.tsx | 65 + apps/app/src/i18n/config.ts | 101 ++ apps/app/src/i18n/ident.ts | 12 + apps/app/src/i18n/messages.ts | 54 + apps/app/src/lib/auth-context.tsx | 18 + apps/app/src/lib/auth.ts | 20 + apps/app/src/lib/env.ts | 21 + apps/app/src/lib/permissions.ts | 21 + apps/app/src/pages/__root.tsx | 78 + .../src/pages/_authenticated.audit-log.tsx | 128 ++ .../src/pages/_authenticated.boats.index.tsx | 394 +++++ .../src/pages/_authenticated.boats.manage.tsx | 450 +++++ .../pages/_authenticated.boats.request.tsx | 128 ++ .../_authenticated.boats.trips.$tripId.tsx | 306 ++++ apps/app/src/pages/_authenticated.boats.tsx | 5 + apps/app/src/pages/_authenticated.finance.tsx | 899 ++++++++++ apps/app/src/pages/_authenticated.index.tsx | 36 + .../src/pages/_authenticated.inventory.tsx | 703 ++++++++ .../pages/_authenticated.kitchen.dietary.tsx | 163 ++ .../pages/_authenticated.kitchen.index.tsx | 265 +++ apps/app/src/pages/_authenticated.kitchen.tsx | 5 + .../src/pages/_authenticated.my.profile.tsx | 274 +++ .../app/src/pages/_authenticated.my.stays.tsx | 237 +++ .../app/src/pages/_authenticated.my.tasks.tsx | 220 +++ .../pages/_authenticated.notifications.tsx | 91 + apps/app/src/pages/_authenticated.profile.tsx | 224 +++ .../_authenticated.retreats.$retreatId.tsx | 896 ++++++++++ .../pages/_authenticated.retreats.index.tsx | 314 ++++ .../app/src/pages/_authenticated.retreats.tsx | 5 + .../_authenticated.rooms.allocations.tsx | 115 ++ .../src/pages/_authenticated.rooms.index.tsx | 314 ++++ apps/app/src/pages/_authenticated.rooms.tsx | 5 + apps/app/src/pages/_authenticated.stays.tsx | 320 ++++ .../src/pages/_authenticated.tasks.index.tsx | 577 +++++++ .../pages/_authenticated.tasks.templates.tsx | 180 ++ apps/app/src/pages/_authenticated.tasks.tsx | 5 + apps/app/src/pages/_authenticated.tsx | 331 ++++ .../pages/_authenticated.users.$userId.tsx | 184 ++ .../src/pages/_authenticated.users.index.tsx | 263 +++ apps/app/src/pages/_authenticated.users.tsx | 5 + apps/app/src/pages/login.tsx | 96 ++ apps/app/src/router.tsx | 16 + apps/app/tsconfig.json | 25 + apps/app/vite.config.ts | 44 + backend/bin/db-migrate.ts | 54 + backend/bin/start-e2e.ts | 73 + backend/bin/start.ts | 29 + backend/package.json | 33 + backend/tsconfig.json | 16 + bun.lock | 1516 +++++++++++++++++ db/annotations.ts | 167 ++ db/sqlite-seed.sql | 13 + db/sqlite/0001-schema.sql | 558 ++++++ db/sqlite/0002-better-auth.sql | 66 + db/sqlite/0003-audit-triggers.sql | 433 +++++ db/sqlite/0004-fabric-audit.sql | 29 + db/sqlite/0005-member-roles-and-admin.sql | 13 + db/sqlite/0006-user-actor.sql | 11 + db/sqlite/0007-fabric.sql | 13 + docs/frontend-architecture.md | 852 +++++++++ e2e/.gitignore | 2 + e2e/package.json | 23 + e2e/tests/cucumber-browser.mjs | 7 + e2e/tests/cucumber.mjs | 6 + e2e/tests/features/audit-log.feature | 14 + e2e/tests/features/auth.feature | 10 + e2e/tests/features/boats.feature | 122 ++ e2e/tests/features/browser/finance.feature | 14 + e2e/tests/features/browser/inventory.feature | 16 + e2e/tests/features/browser/login.feature | 15 + e2e/tests/features/browser/navigation.feature | 63 + e2e/tests/features/browser/rooms.feature | 15 + e2e/tests/features/browser/stays.feature | 15 + e2e/tests/features/finance.feature | 58 + e2e/tests/features/health-check.feature | 6 + e2e/tests/features/inventory.feature | 77 + e2e/tests/features/kitchen.feature | 29 + e2e/tests/features/notifications.feature | 14 + e2e/tests/features/retreats.feature | 68 + e2e/tests/features/roles.feature | 20 + e2e/tests/features/rooms.feature | 105 ++ e2e/tests/features/stays.feature | 102 ++ e2e/tests/features/tasks.feature | 87 + e2e/tests/features/users.feature | 24 + e2e/tests/steps/audit-log.steps.ts | 12 + e2e/tests/steps/auth.steps.ts | 6 + e2e/tests/steps/boats.steps.ts | 64 + e2e/tests/steps/browser/common.steps.ts | 141 ++ e2e/tests/steps/finance.steps.ts | 38 + e2e/tests/steps/health-check.steps.ts | 10 + e2e/tests/steps/inventory.steps.ts | 49 + e2e/tests/steps/kitchen.steps.ts | 22 + e2e/tests/steps/notifications.steps.ts | 29 + e2e/tests/steps/retreats.steps.ts | 59 + e2e/tests/steps/roles.steps.ts | 22 + e2e/tests/steps/rooms.steps.ts | 51 + e2e/tests/steps/shared.steps.ts | 19 + e2e/tests/steps/stays.steps.ts | 57 + e2e/tests/steps/tasks.steps.ts | 63 + e2e/tests/steps/users.steps.ts | 37 + e2e/tests/support/api-client.ts | 58 + e2e/tests/support/browser-config.ts | 22 + e2e/tests/support/browser-hooks.ts | 136 ++ e2e/tests/support/browser-world.ts | 147 ++ e2e/tests/support/config.ts | 19 + e2e/tests/support/db.ts | 66 + e2e/tests/support/hooks.ts | 95 ++ e2e/tests/support/world.ts | 124 ++ e2e/tsconfig.json | 13 + emails/locales/de.json | 14 + emails/locales/en.json | 14 + emails/partials/footer.html | 3 + emails/partials/layout.html | 15 + emails/templates/confirm-email.html | 27 + emails/templates/confirm-email.subject.txt | 1 + emails/templates/confirm-email.text.txt | 7 + emails/theme.json | 16 + package.json | 55 + packages/components/package.json | 20 + packages/components/src/data-table.tsx | 131 ++ packages/components/src/index.ts | 3 + packages/components/src/page-header.tsx | 32 + packages/components/src/status-badge.tsx | 37 + packages/functions-sdk/package.json | 20 + packages/functions/package.json | 37 + packages/functions/src/agents/admin.agent.ts | 149 ++ packages/functions/src/agents/boats.agent.ts | 35 + .../functions/src/agents/finance.agent.ts | 27 + .../functions/src/agents/inventory.agent.ts | 26 + .../functions/src/agents/kitchen.agent.ts | 22 + .../functions/src/agents/retreats.agent.ts | 29 + packages/functions/src/agents/rooms.agent.ts | 24 + packages/functions/src/agents/router.agent.ts | 35 + packages/functions/src/agents/stays.agent.ts | 26 + packages/functions/src/agents/tasks.agent.ts | 29 + packages/functions/src/application-types.d.ts | 26 + packages/functions/src/config.ts | 20 + .../audit/list-audit-log.function.ts | 56 + .../boats/cancel-boat-trip.function.ts | 58 + .../boats/complete-boat-trip.function.ts | 45 + .../boats/confirm-boat-request.function.ts | 87 + .../boats/create-boat-route.function.ts | 36 + .../boats/create-boat-trip.function.ts | 43 + .../functions/boats/create-boat.function.ts | 38 + .../boats/decline-boat-request.function.ts | 52 + .../boats/get-boat-manifest.function.ts | 71 + .../boats/list-boat-requests.function.ts | 65 + .../boats/list-boat-routes.function.ts | 44 + .../boats/list-boat-trips.function.ts | 58 + .../functions/boats/list-boats.function.ts | 50 + .../boats/open-boat-trip.function.ts | 45 + .../boats/request-boat-seat.function.ts | 62 + .../boats/update-boat-route.function.ts | 53 + .../boats/update-boat-trip.function.ts | 58 + .../functions/boats/update-boat.function.ts | 55 + .../dashboard/get-dashboard-stats.function.ts | 105 ++ .../finance/create-finance-record.function.ts | 88 + .../finance/fetch-exchange-rates.function.ts | 76 + .../finance/get-exchange-rates.function.ts | 59 + .../finance/get-finance-summary.function.ts | 118 ++ .../finance/link-finance-record.function.ts | 54 + .../finance/list-finance-links.function.ts | 89 + .../finance/list-finance-records.function.ts | 76 + .../finance/update-finance-record.function.ts | 83 + .../src/functions/health-check.function.ts | 17 + .../inventory/adjust-inventory.function.ts | 69 + .../inventory/check-low-stock.function.ts | 67 + .../create-inventory-item.function.ts | 56 + .../fulfill-inventory-request.function.ts | 60 + .../list-inventory-transactions.function.ts | 55 + .../inventory/list-inventory.function.ts | 59 + .../inventory/request-inventory.function.ts | 54 + .../review-inventory-request.function.ts | 51 + .../inventory/submit-stocktake.function.ts | 87 + .../update-inventory-item.function.ts | 77 + .../kitchen/create-meal-service.function.ts | 43 + .../kitchen/create-retreat-meal.function.ts | 43 + .../generate-meal-attendance.function.ts | 96 ++ .../get-meal-dietary-summary.function.ts | 78 + .../kitchen/list-meal-attendance.function.ts | 52 + .../kitchen/list-meal-services.function.ts | 54 + .../kitchen/list-retreat-meals.function.ts | 44 + .../upsert-dietary-profile.function.ts | 60 + .../create-notification.function.ts | 43 + .../list-notifications.function.ts | 44 + .../mark-notification-read.function.ts | 42 + .../retreats/add-retreat-person.function.ts | 55 + .../add-retreat-schedule-item.function.ts | 53 + .../retreats/cancel-retreat.function.ts | 43 + .../retreats/create-retreat.function.ts | 45 + .../retreats/list-retreat-persons.function.ts | 45 + .../list-retreat-schedule.function.ts | 45 + .../retreats/list-retreats.function.ts | 75 + .../notify-retreat-participants.function.ts | 50 + .../retreats/publish-retreat.function.ts | 43 + .../retreats/update-retreat.function.ts | 59 + .../functions/roles/list-roles.function.ts | 25 + .../roles/set-user-roles.function.ts | 40 + .../functions/rooms/allocate-room.function.ts | 74 + .../rooms/check-room-availability.function.ts | 69 + .../functions/rooms/create-room.function.ts | 42 + .../rooms/list-room-allocations.function.ts | 58 + .../functions/rooms/list-rooms.function.ts | 50 + .../functions/rooms/release-room.function.ts | 49 + .../functions/rooms/update-room.function.ts | 57 + .../stays/approve-stay-request.function.ts | 52 + .../functions/stays/cancel-stay.function.ts | 50 + .../functions/stays/check-in-stay.function.ts | 50 + .../stays/check-out-stay.function.ts | 50 + .../create-stay-from-request.function.ts | 60 + .../stays/list-stay-requests.function.ts | 72 + .../functions/stays/list-stays.function.ts | 80 + .../stays/reject-stay-request.function.ts | 52 + .../functions/stays/request-stay.function.ts | 43 + .../tasks/assign-task-to-role.function.ts | 44 + .../functions/tasks/assign-task.function.ts | 57 + .../functions/tasks/claim-task.function.ts | 60 + .../functions/tasks/complete-task.function.ts | 55 + .../tasks/create-task-instance.function.ts | 47 + .../tasks/create-task-template.function.ts | 45 + .../generate-recurring-tasks.function.ts | 112 ++ .../functions/tasks/list-tasks.function.ts | 65 + .../tasks/mark-task-blocked.function.ts | 46 + .../functions/tasks/start-task.function.ts | 43 + .../functions/tasks/update-task.function.ts | 61 + .../src/functions/users/get-user.function.ts | 39 + .../functions/users/invite-user.function.ts | 57 + .../functions/users/list-users.function.ts | 46 + .../users/update-user-profile.function.ts | 57 + packages/functions/src/lib/audit-context.ts | 14 + packages/functions/src/lib/errors.ts | 34 + packages/functions/src/lib/notifications.ts | 26 + packages/functions/src/lib/permissions.ts | 94 + packages/functions/src/lib/roles.ts | 126 ++ packages/functions/src/lib/state-machine.ts | 76 + packages/functions/src/middleware.ts | 51 + packages/functions/src/services.ts | 72 + packages/functions/src/wirings/auth.wiring.ts | 54 + .../src/wirings/http-phase1b.wiring.ts | 238 +++ .../src/wirings/http-phase2.wiring.ts | 285 ++++ packages/functions/src/wirings/http.wiring.ts | 184 ++ packages/functions/tests/package.json | 24 + packages/functions/tests/tests/cucumber.mjs | 7 + .../functions/tests/tests/features/.gitkeep | 0 .../functions/tests/tests/support/hooks.ts | 7 + .../functions/tests/tests/support/services.ts | 14 + .../functions/tests/tests/support/world.ts | 5 + packages/functions/tests/tsconfig.json | 18 + packages/functions/tsconfig.json | 22 + packages/mantine-theme/package.json | 12 + packages/mantine-theme/src/index.ts | 28 + pikku.config.json | 33 + pikkufabric.config.json | 16 + sql/0001-init.sql | 50 + sql/0002-audit-log.sql | 88 + sql/0003-seed-data.sql | 3 + sql/0004-stays.sql | 44 + sql/0005-rooms.sql | 65 + sql/0006-boats.sql | 84 + sql/0007-tasks.sql | 56 + sql/0008-notifications.sql | 13 + sql/0009-retreats.sql | 80 + sql/0010-kitchen.sql | 76 + sql/0011-inventory.sql | 49 + sql/0012-inventory-categories.sql | 6 + sql/0013-finance.sql | 38 + sql/0014-inventory-transactions.sql | 11 + sql/0015-task-role-assignment.sql | 1 + sql/0016-user-phone-fields.sql | 4 + sql/0017-room-price.sql | 2 + sql/0018-seed-demo-data.sql | 327 ++++ sql/0019-exchange-rates.sql | 12 + sql/0020-finance-base-amount.sql | 5 + sql/0021-user-avatar.sql | 1 + tsconfig.json | 27 + 293 files changed, 28421 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 PLATFORM-REVIEW.md create mode 100644 README.md create mode 100644 ai/memory-bank/tasks/perauset-implementation-plan.md create mode 100644 ai/memory-bank/ux-architecture.md create mode 100644 apps/app/messages/de.json create mode 100644 apps/app/messages/en.json create mode 100644 apps/app/package.json create mode 100644 apps/app/project.inlang/settings.json create mode 100644 apps/app/src/components/ai-chat.tsx create mode 100644 apps/app/src/components/create-template-form.tsx create mode 100644 apps/app/src/components/locale-switcher.tsx create mode 100644 apps/app/src/components/notification-bell.tsx create mode 100644 apps/app/src/components/notification-row.tsx create mode 100644 apps/app/src/components/stays-tabs.tsx create mode 100644 apps/app/src/components/task-actions.tsx create mode 100644 apps/app/src/components/user-select.tsx create mode 100644 apps/app/src/i18n/config.ts create mode 100644 apps/app/src/i18n/ident.ts create mode 100644 apps/app/src/i18n/messages.ts create mode 100644 apps/app/src/lib/auth-context.tsx create mode 100644 apps/app/src/lib/auth.ts create mode 100644 apps/app/src/lib/env.ts create mode 100644 apps/app/src/lib/permissions.ts create mode 100644 apps/app/src/pages/__root.tsx create mode 100644 apps/app/src/pages/_authenticated.audit-log.tsx create mode 100644 apps/app/src/pages/_authenticated.boats.index.tsx create mode 100644 apps/app/src/pages/_authenticated.boats.manage.tsx create mode 100644 apps/app/src/pages/_authenticated.boats.request.tsx create mode 100644 apps/app/src/pages/_authenticated.boats.trips.$tripId.tsx create mode 100644 apps/app/src/pages/_authenticated.boats.tsx create mode 100644 apps/app/src/pages/_authenticated.finance.tsx create mode 100644 apps/app/src/pages/_authenticated.index.tsx create mode 100644 apps/app/src/pages/_authenticated.inventory.tsx create mode 100644 apps/app/src/pages/_authenticated.kitchen.dietary.tsx create mode 100644 apps/app/src/pages/_authenticated.kitchen.index.tsx create mode 100644 apps/app/src/pages/_authenticated.kitchen.tsx create mode 100644 apps/app/src/pages/_authenticated.my.profile.tsx create mode 100644 apps/app/src/pages/_authenticated.my.stays.tsx create mode 100644 apps/app/src/pages/_authenticated.my.tasks.tsx create mode 100644 apps/app/src/pages/_authenticated.notifications.tsx create mode 100644 apps/app/src/pages/_authenticated.profile.tsx create mode 100644 apps/app/src/pages/_authenticated.retreats.$retreatId.tsx create mode 100644 apps/app/src/pages/_authenticated.retreats.index.tsx create mode 100644 apps/app/src/pages/_authenticated.retreats.tsx create mode 100644 apps/app/src/pages/_authenticated.rooms.allocations.tsx create mode 100644 apps/app/src/pages/_authenticated.rooms.index.tsx create mode 100644 apps/app/src/pages/_authenticated.rooms.tsx create mode 100644 apps/app/src/pages/_authenticated.stays.tsx create mode 100644 apps/app/src/pages/_authenticated.tasks.index.tsx create mode 100644 apps/app/src/pages/_authenticated.tasks.templates.tsx create mode 100644 apps/app/src/pages/_authenticated.tasks.tsx create mode 100644 apps/app/src/pages/_authenticated.tsx create mode 100644 apps/app/src/pages/_authenticated.users.$userId.tsx create mode 100644 apps/app/src/pages/_authenticated.users.index.tsx create mode 100644 apps/app/src/pages/_authenticated.users.tsx create mode 100644 apps/app/src/pages/login.tsx create mode 100644 apps/app/src/router.tsx create mode 100644 apps/app/tsconfig.json create mode 100644 apps/app/vite.config.ts create mode 100644 backend/bin/db-migrate.ts create mode 100644 backend/bin/start-e2e.ts create mode 100644 backend/bin/start.ts create mode 100644 backend/package.json create mode 100644 backend/tsconfig.json create mode 100644 bun.lock create mode 100644 db/annotations.ts create mode 100644 db/sqlite-seed.sql create mode 100644 db/sqlite/0001-schema.sql create mode 100644 db/sqlite/0002-better-auth.sql create mode 100644 db/sqlite/0003-audit-triggers.sql create mode 100644 db/sqlite/0004-fabric-audit.sql create mode 100644 db/sqlite/0005-member-roles-and-admin.sql create mode 100644 db/sqlite/0006-user-actor.sql create mode 100644 db/sqlite/0007-fabric.sql create mode 100644 docs/frontend-architecture.md create mode 100644 e2e/.gitignore create mode 100644 e2e/package.json create mode 100644 e2e/tests/cucumber-browser.mjs create mode 100644 e2e/tests/cucumber.mjs create mode 100644 e2e/tests/features/audit-log.feature create mode 100644 e2e/tests/features/auth.feature create mode 100644 e2e/tests/features/boats.feature create mode 100644 e2e/tests/features/browser/finance.feature create mode 100644 e2e/tests/features/browser/inventory.feature create mode 100644 e2e/tests/features/browser/login.feature create mode 100644 e2e/tests/features/browser/navigation.feature create mode 100644 e2e/tests/features/browser/rooms.feature create mode 100644 e2e/tests/features/browser/stays.feature create mode 100644 e2e/tests/features/finance.feature create mode 100644 e2e/tests/features/health-check.feature create mode 100644 e2e/tests/features/inventory.feature create mode 100644 e2e/tests/features/kitchen.feature create mode 100644 e2e/tests/features/notifications.feature create mode 100644 e2e/tests/features/retreats.feature create mode 100644 e2e/tests/features/roles.feature create mode 100644 e2e/tests/features/rooms.feature create mode 100644 e2e/tests/features/stays.feature create mode 100644 e2e/tests/features/tasks.feature create mode 100644 e2e/tests/features/users.feature create mode 100644 e2e/tests/steps/audit-log.steps.ts create mode 100644 e2e/tests/steps/auth.steps.ts create mode 100644 e2e/tests/steps/boats.steps.ts create mode 100644 e2e/tests/steps/browser/common.steps.ts create mode 100644 e2e/tests/steps/finance.steps.ts create mode 100644 e2e/tests/steps/health-check.steps.ts create mode 100644 e2e/tests/steps/inventory.steps.ts create mode 100644 e2e/tests/steps/kitchen.steps.ts create mode 100644 e2e/tests/steps/notifications.steps.ts create mode 100644 e2e/tests/steps/retreats.steps.ts create mode 100644 e2e/tests/steps/roles.steps.ts create mode 100644 e2e/tests/steps/rooms.steps.ts create mode 100644 e2e/tests/steps/shared.steps.ts create mode 100644 e2e/tests/steps/stays.steps.ts create mode 100644 e2e/tests/steps/tasks.steps.ts create mode 100644 e2e/tests/steps/users.steps.ts create mode 100644 e2e/tests/support/api-client.ts create mode 100644 e2e/tests/support/browser-config.ts create mode 100644 e2e/tests/support/browser-hooks.ts create mode 100644 e2e/tests/support/browser-world.ts create mode 100644 e2e/tests/support/config.ts create mode 100644 e2e/tests/support/db.ts create mode 100644 e2e/tests/support/hooks.ts create mode 100644 e2e/tests/support/world.ts create mode 100644 e2e/tsconfig.json create mode 100644 emails/locales/de.json create mode 100644 emails/locales/en.json create mode 100644 emails/partials/footer.html create mode 100644 emails/partials/layout.html create mode 100644 emails/templates/confirm-email.html create mode 100644 emails/templates/confirm-email.subject.txt create mode 100644 emails/templates/confirm-email.text.txt create mode 100644 emails/theme.json create mode 100644 package.json create mode 100644 packages/components/package.json create mode 100644 packages/components/src/data-table.tsx create mode 100644 packages/components/src/index.ts create mode 100644 packages/components/src/page-header.tsx create mode 100644 packages/components/src/status-badge.tsx create mode 100644 packages/functions-sdk/package.json create mode 100644 packages/functions/package.json create mode 100644 packages/functions/src/agents/admin.agent.ts create mode 100644 packages/functions/src/agents/boats.agent.ts create mode 100644 packages/functions/src/agents/finance.agent.ts create mode 100644 packages/functions/src/agents/inventory.agent.ts create mode 100644 packages/functions/src/agents/kitchen.agent.ts create mode 100644 packages/functions/src/agents/retreats.agent.ts create mode 100644 packages/functions/src/agents/rooms.agent.ts create mode 100644 packages/functions/src/agents/router.agent.ts create mode 100644 packages/functions/src/agents/stays.agent.ts create mode 100644 packages/functions/src/agents/tasks.agent.ts create mode 100644 packages/functions/src/application-types.d.ts create mode 100644 packages/functions/src/config.ts create mode 100644 packages/functions/src/functions/audit/list-audit-log.function.ts create mode 100644 packages/functions/src/functions/boats/cancel-boat-trip.function.ts create mode 100644 packages/functions/src/functions/boats/complete-boat-trip.function.ts create mode 100644 packages/functions/src/functions/boats/confirm-boat-request.function.ts create mode 100644 packages/functions/src/functions/boats/create-boat-route.function.ts create mode 100644 packages/functions/src/functions/boats/create-boat-trip.function.ts create mode 100644 packages/functions/src/functions/boats/create-boat.function.ts create mode 100644 packages/functions/src/functions/boats/decline-boat-request.function.ts create mode 100644 packages/functions/src/functions/boats/get-boat-manifest.function.ts create mode 100644 packages/functions/src/functions/boats/list-boat-requests.function.ts create mode 100644 packages/functions/src/functions/boats/list-boat-routes.function.ts create mode 100644 packages/functions/src/functions/boats/list-boat-trips.function.ts create mode 100644 packages/functions/src/functions/boats/list-boats.function.ts create mode 100644 packages/functions/src/functions/boats/open-boat-trip.function.ts create mode 100644 packages/functions/src/functions/boats/request-boat-seat.function.ts create mode 100644 packages/functions/src/functions/boats/update-boat-route.function.ts create mode 100644 packages/functions/src/functions/boats/update-boat-trip.function.ts create mode 100644 packages/functions/src/functions/boats/update-boat.function.ts create mode 100644 packages/functions/src/functions/dashboard/get-dashboard-stats.function.ts create mode 100644 packages/functions/src/functions/finance/create-finance-record.function.ts create mode 100644 packages/functions/src/functions/finance/fetch-exchange-rates.function.ts create mode 100644 packages/functions/src/functions/finance/get-exchange-rates.function.ts create mode 100644 packages/functions/src/functions/finance/get-finance-summary.function.ts create mode 100644 packages/functions/src/functions/finance/link-finance-record.function.ts create mode 100644 packages/functions/src/functions/finance/list-finance-links.function.ts create mode 100644 packages/functions/src/functions/finance/list-finance-records.function.ts create mode 100644 packages/functions/src/functions/finance/update-finance-record.function.ts create mode 100644 packages/functions/src/functions/health-check.function.ts create mode 100644 packages/functions/src/functions/inventory/adjust-inventory.function.ts create mode 100644 packages/functions/src/functions/inventory/check-low-stock.function.ts create mode 100644 packages/functions/src/functions/inventory/create-inventory-item.function.ts create mode 100644 packages/functions/src/functions/inventory/fulfill-inventory-request.function.ts create mode 100644 packages/functions/src/functions/inventory/list-inventory-transactions.function.ts create mode 100644 packages/functions/src/functions/inventory/list-inventory.function.ts create mode 100644 packages/functions/src/functions/inventory/request-inventory.function.ts create mode 100644 packages/functions/src/functions/inventory/review-inventory-request.function.ts create mode 100644 packages/functions/src/functions/inventory/submit-stocktake.function.ts create mode 100644 packages/functions/src/functions/inventory/update-inventory-item.function.ts create mode 100644 packages/functions/src/functions/kitchen/create-meal-service.function.ts create mode 100644 packages/functions/src/functions/kitchen/create-retreat-meal.function.ts create mode 100644 packages/functions/src/functions/kitchen/generate-meal-attendance.function.ts create mode 100644 packages/functions/src/functions/kitchen/get-meal-dietary-summary.function.ts create mode 100644 packages/functions/src/functions/kitchen/list-meal-attendance.function.ts create mode 100644 packages/functions/src/functions/kitchen/list-meal-services.function.ts create mode 100644 packages/functions/src/functions/kitchen/list-retreat-meals.function.ts create mode 100644 packages/functions/src/functions/kitchen/upsert-dietary-profile.function.ts create mode 100644 packages/functions/src/functions/notifications/create-notification.function.ts create mode 100644 packages/functions/src/functions/notifications/list-notifications.function.ts create mode 100644 packages/functions/src/functions/notifications/mark-notification-read.function.ts create mode 100644 packages/functions/src/functions/retreats/add-retreat-person.function.ts create mode 100644 packages/functions/src/functions/retreats/add-retreat-schedule-item.function.ts create mode 100644 packages/functions/src/functions/retreats/cancel-retreat.function.ts create mode 100644 packages/functions/src/functions/retreats/create-retreat.function.ts create mode 100644 packages/functions/src/functions/retreats/list-retreat-persons.function.ts create mode 100644 packages/functions/src/functions/retreats/list-retreat-schedule.function.ts create mode 100644 packages/functions/src/functions/retreats/list-retreats.function.ts create mode 100644 packages/functions/src/functions/retreats/notify-retreat-participants.function.ts create mode 100644 packages/functions/src/functions/retreats/publish-retreat.function.ts create mode 100644 packages/functions/src/functions/retreats/update-retreat.function.ts create mode 100644 packages/functions/src/functions/roles/list-roles.function.ts create mode 100644 packages/functions/src/functions/roles/set-user-roles.function.ts create mode 100644 packages/functions/src/functions/rooms/allocate-room.function.ts create mode 100644 packages/functions/src/functions/rooms/check-room-availability.function.ts create mode 100644 packages/functions/src/functions/rooms/create-room.function.ts create mode 100644 packages/functions/src/functions/rooms/list-room-allocations.function.ts create mode 100644 packages/functions/src/functions/rooms/list-rooms.function.ts create mode 100644 packages/functions/src/functions/rooms/release-room.function.ts create mode 100644 packages/functions/src/functions/rooms/update-room.function.ts create mode 100644 packages/functions/src/functions/stays/approve-stay-request.function.ts create mode 100644 packages/functions/src/functions/stays/cancel-stay.function.ts create mode 100644 packages/functions/src/functions/stays/check-in-stay.function.ts create mode 100644 packages/functions/src/functions/stays/check-out-stay.function.ts create mode 100644 packages/functions/src/functions/stays/create-stay-from-request.function.ts create mode 100644 packages/functions/src/functions/stays/list-stay-requests.function.ts create mode 100644 packages/functions/src/functions/stays/list-stays.function.ts create mode 100644 packages/functions/src/functions/stays/reject-stay-request.function.ts create mode 100644 packages/functions/src/functions/stays/request-stay.function.ts create mode 100644 packages/functions/src/functions/tasks/assign-task-to-role.function.ts create mode 100644 packages/functions/src/functions/tasks/assign-task.function.ts create mode 100644 packages/functions/src/functions/tasks/claim-task.function.ts create mode 100644 packages/functions/src/functions/tasks/complete-task.function.ts create mode 100644 packages/functions/src/functions/tasks/create-task-instance.function.ts create mode 100644 packages/functions/src/functions/tasks/create-task-template.function.ts create mode 100644 packages/functions/src/functions/tasks/generate-recurring-tasks.function.ts create mode 100644 packages/functions/src/functions/tasks/list-tasks.function.ts create mode 100644 packages/functions/src/functions/tasks/mark-task-blocked.function.ts create mode 100644 packages/functions/src/functions/tasks/start-task.function.ts create mode 100644 packages/functions/src/functions/tasks/update-task.function.ts create mode 100644 packages/functions/src/functions/users/get-user.function.ts create mode 100644 packages/functions/src/functions/users/invite-user.function.ts create mode 100644 packages/functions/src/functions/users/list-users.function.ts create mode 100644 packages/functions/src/functions/users/update-user-profile.function.ts create mode 100644 packages/functions/src/lib/audit-context.ts create mode 100644 packages/functions/src/lib/errors.ts create mode 100644 packages/functions/src/lib/notifications.ts create mode 100644 packages/functions/src/lib/permissions.ts create mode 100644 packages/functions/src/lib/roles.ts create mode 100644 packages/functions/src/lib/state-machine.ts create mode 100644 packages/functions/src/middleware.ts create mode 100644 packages/functions/src/services.ts create mode 100644 packages/functions/src/wirings/auth.wiring.ts create mode 100644 packages/functions/src/wirings/http-phase1b.wiring.ts create mode 100644 packages/functions/src/wirings/http-phase2.wiring.ts create mode 100644 packages/functions/src/wirings/http.wiring.ts create mode 100644 packages/functions/tests/package.json create mode 100644 packages/functions/tests/tests/cucumber.mjs create mode 100644 packages/functions/tests/tests/features/.gitkeep create mode 100644 packages/functions/tests/tests/support/hooks.ts create mode 100644 packages/functions/tests/tests/support/services.ts create mode 100644 packages/functions/tests/tests/support/world.ts create mode 100644 packages/functions/tests/tsconfig.json create mode 100644 packages/functions/tsconfig.json create mode 100644 packages/mantine-theme/package.json create mode 100644 packages/mantine-theme/src/index.ts create mode 100644 pikku.config.json create mode 100644 pikkufabric.config.json create mode 100644 sql/0001-init.sql create mode 100644 sql/0002-audit-log.sql create mode 100644 sql/0003-seed-data.sql create mode 100644 sql/0004-stays.sql create mode 100644 sql/0005-rooms.sql create mode 100644 sql/0006-boats.sql create mode 100644 sql/0007-tasks.sql create mode 100644 sql/0008-notifications.sql create mode 100644 sql/0009-retreats.sql create mode 100644 sql/0010-kitchen.sql create mode 100644 sql/0011-inventory.sql create mode 100644 sql/0012-inventory-categories.sql create mode 100644 sql/0013-finance.sql create mode 100644 sql/0014-inventory-transactions.sql create mode 100644 sql/0015-task-role-assignment.sql create mode 100644 sql/0016-user-phone-fields.sql create mode 100644 sql/0017-room-price.sql create mode 100644 sql/0018-seed-demo-data.sql create mode 100644 sql/0019-exchange-rates.sql create mode 100644 sql/0020-finance-base-amount.sql create mode 100644 sql/0021-user-avatar.sql create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a5ae5f --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DB_HOST=0.0.0.0 +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=password +DB_NAME=perauset +BETTER_AUTH_SECRET=change-me +PORT=6002 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77d6be6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage +coverage +*.lcov +.nyc_output + +# Dependency directories +node_modules/ + +# TypeScript cache +*.tsbuildinfo + +# dotenv environment variable files +.env +.env.* +!.env.example + +# Build output +dist +.next/ + +# Bun +.bun/ + +# Pikku generated files +.pikku/ +.deploy/ +.pikku-runtime/ + +# All codegen output — regenerated by the build (pikku all / prebuild). +# Never track *.gen.* (e.g. db/annotations.gen.json, *.gen.ts, *.gen.d.ts). +*.gen.* + +# tsconfig build info +apps/app/tsconfig.tsbuildinfo + +# Claude +.claude/skills/ +.playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4e08b1a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# Perauset + +Village operations platform. + +## Project Structure + +- `backend/` — Backend server (uWebSockets + Pikku) +- `packages/functions/` — Shared business logic / Pikku functions +- `sql/` — Database migrations (postgres-migrations) + +## Running + +### Backend + +```sh +cd backend && yarn dev +``` + +Runs `tsx watch` with env files (`../.env` and `.env.local`). Watches for changes and auto-restarts. + +### Port + +| Service | Port | +|---------|------| +| Backend | 6002 | + +## Commands + +- `yarn pikku` — Run Pikku CLI codegen (`pikku all`) +- `yarn pikku:watch` — Run Pikku CLI codegen in watch mode +- `yarn prebuild` — Bootstrap + codegen +- `yarn tsc` — Type-check all workspaces +- `cd backend && yarn dbmigrate` — Run database migrations +- `cd packages/functions && yarn db:types` — Regenerate Kysely DB types + +## Package Manager + +Uses **Yarn 4.9.2** (with workspaces). Node >= 22 required. + +## Database + +PostgreSQL, database name `perauset`, default schema `app`. diff --git a/PLATFORM-REVIEW.md b/PLATFORM-REVIEW.md new file mode 100644 index 0000000..18b1fe1 --- /dev/null +++ b/PLATFORM-REVIEW.md @@ -0,0 +1,260 @@ +# PerAuset Platform Review -- Daily Operations Gaps + +**Reviewer**: Alex (Product Manager) +**Date**: 2026-03-19 +**Scope**: Full platform review from the perspective of a daily coordinator, kitchen lead, boat coordinator, and facilitator +**Method**: Reviewed all 66 RPC functions, 15+ frontend pages, 13 database migrations, and the role/permission system + +--- + +## Critical (Blocks Daily Operations) + +### 1. [Dashboard] All stats are hardcoded "--" except notifications +The dashboard shows "Active Stays: --", "Upcoming Boats: --", "Open Tasks: --". There is no backend function to fetch dashboard summary stats. A coordinator opening the app every morning gets zero situational awareness. + +**What to build**: A `getDashboardStats` RPC that queries active stays count, upcoming boat trips count, and open task count. Update the dashboard page to call it. + +### 2. [Kitchen] Cannot create a meal service from the UI +The "Create Meal" button on the kitchen page is explicitly `disabled`. The backend `createMealService` RPC exists, but the frontend form to call it is missing. A kitchen lead cannot plan any meals. + +**What to build**: A meal creation drawer/page that calls `createMealService` with service_type, service_date, planned_headcount, and menu_notes. + +### 3. [Kitchen] No way to see who is eating (meal headcount from stays/retreats) +The `meal_attendance` table exists and links to stays and retreats, but there is no RPC to list meal attendance, no RPC to auto-populate attendance from active stays, and the frontend shows zero attendance data. A kitchen lead cannot know how many mouths to feed or what dietary needs exist for a given meal. + +**What to build**: (a) `listMealAttendance` RPC filtered by meal_service_id. (b) `generateMealAttendance` RPC that auto-creates attendance records from active stays and retreat participants for a date range. (c) Frontend attendance list on each meal card showing headcount and dietary summary. + +### 4. [Kitchen] No dietary summary per meal +Dietary profiles exist per user, but there is no function that aggregates dietary requirements for a specific meal (e.g., "3 vegan, 2 gluten-free, 1 nut allergy"). Without this, the kitchen lead has to manually cross-reference every guest's profile. + +**What to build**: `getMealDietarySummary` RPC that joins meal_attendance with dietary_profile and returns counts. Show this on each meal card. + +### 5. [Retreats] People tab is always empty -- not wired to backend +The retreat detail page hardcodes `const people: any[] = []` and `const schedule: any[] = []`. Despite `addRetreatPerson` and `addRetreatScheduleItem` RPCs existing, there is no `listRetreatPersons` or `listRetreatScheduleItems` RPC, and the "Add Person" button is `disabled`. A facilitator cannot manage retreat participants or view the schedule. + +**What to build**: (a) `listRetreatPersons` and `listRetreatScheduleItems` RPCs. (b) Wire the retreat detail page to call them. (c) Enable the "Add Person" and "Add Schedule Item" forms calling the existing RPCs. + +### 6. [Retreats] No `getRetreat` RPC -- detail page re-fetches all retreats +The retreat detail page calls `listRetreats` and filters client-side by ID. This means no additional detail (persons, schedule) is loaded. There is no single-retreat fetch endpoint. + +**What to build**: `getRetreat` RPC that returns the retreat with its persons and schedule items in one call. + +### 7. [Stays] No "who is here right now" view +There is no way to see currently checked-in guests. The stays list shows all stays but does not filter by "checked_in" status or date overlap with today. A coordinator doing a morning roll call has no tool for this. + +**What to build**: Add a "Currently Checked In" filter/tab to the stays page that queries stays with status='checked_in' or where today falls within start_at/end_at. Ideally also surface this count on the dashboard. + +### 8. [Rooms] No room availability / availability calendar +You can see rooms and their allocations, but there is no way to check "which rooms are available from date X to date Y?" before making an allocation. This means room assignment is guesswork. + +**What to build**: `checkRoomAvailability` RPC that accepts a date range and returns rooms with their open capacity. Frontend calendar or date-picker view on the rooms page. + +--- + +## Important (Significantly Impacts Efficiency) + +### 9. [Kitchen] No weekly menu planning +Meals are individual records with no concept of a menu or weekly plan. A kitchen lead has to create each meal one at a time with no template or copy-week functionality. + +**What to build**: `generateWeeklyMeals` RPC that bulk-creates meal_service records for breakfast/lunch/dinner across a date range. Frontend weekly calendar view for kitchen. + +### 10. [Kitchen] No shopping list generation from menu +There is no `meal_ingredient` or `recipe` table. The menu_notes field is free text. There is no connection between meals and inventory items. A kitchen lead cannot generate a shopping list from the planned menu. + +**What to build**: New `meal_ingredient` table linking meal_service to inventory_item with quantity. `generateShoppingList` RPC that diffs meal ingredients against inventory. + +### 11. [Boats] No passenger manifest view +The trip detail page shows seat requests, but there is no consolidated manifest showing confirmed passengers with their names, cargo notes, and special requirements. The boat coordinator needs a printable list. + +**What to build**: `getBoatManifest` RPC that returns confirmed seat requests joined with user profiles. Frontend manifest card on the trip detail page. + +### 12. [Boats] No weather cancellation / rebooking workflow +Cancelling a trip (`cancelBoatTrip`) does not notify affected passengers or offer rebooking. Seat requests just sit in their current state. + +**What to build**: When a trip is cancelled, auto-create notifications for all confirmed seat request holders. Add a `rebookSeatRequest` RPC that moves a request to another trip. + +### 13. [Tasks] No recurring task generation from templates +Task templates have a `recurrence_cron` field, but there is no scheduler or function that creates task instances from templates based on the cron expression. Recurring tasks (daily cleaning, weekly maintenance) must be created manually every time. + +**What to build**: A scheduled function (cron job) `generateRecurringTasks` that reads active templates with recurrence_cron and creates task instances for upcoming periods. + +### 14. [Tasks] No task history / completion stats +There is no way to see completed tasks over time, completion rates, or who completed the most tasks. No analytics view exists. + +**What to build**: `getTaskStats` RPC returning completion counts by category, by user, and by time period. Frontend stats view on the tasks page. + +### 15. [Inventory] No stocktake functionality +You can edit item quantities one at a time, but there is no bulk stocktake flow where someone walks through the storage room updating all quantities at once. + +**What to build**: `submitStocktake` RPC that accepts an array of {itemId, actualQuantity} and updates all in one transaction with audit trail. Frontend stocktake page with a checklist. + +### 16. [Inventory] No consumption history +When quantities are manually edited, the old value is lost (only the audit log captures it). There is no `inventory_transaction` table tracking additions/removals with reasons. + +**What to build**: New `inventory_transaction` table (item_id, quantity_change, reason, user_id, created_at). `adjustInventory` RPC that creates a transaction record. Frontend history tab per item. + +### 17. [Inventory] No automatic reorder alerts +Items have `minimum_quantity`, and the UI shows a yellow warning icon, but there is no notification sent when stock drops below minimum. The kitchen lead has to manually check the inventory page. + +**What to build**: Trigger (database or application-level) that creates a notification when `current_quantity` drops below `minimum_quantity`. + +### 18. [Finance] No totals, summaries, or reports +The finance page lists individual records but shows no totals by category, no monthly summaries, no income-vs-expense breakdown. You cannot generate a monthly report. + +**What to build**: `getFinanceSummary` RPC returning totals by category, by record_type, and by month. Frontend summary cards at the top of the finance page. + +### 19. [Finance] No link to retreat or project for cost tracking +The `finance_link` table exists and the `linkFinanceRecord` RPC exists, but the frontend has no UI to link a finance record to a retreat, boat trip, stay, or inventory request. You cannot track per-retreat costs. + +**What to build**: Add a "Link to Entity" button on finance records that opens a selector for retreat/trip/stay/item. Show linked entity badges on the finance table. + +### 20. [Finance] No approval workflow +`finance_record` has no `approved_by` or `approval_status` field. Anyone with `finance.manage` permission (which is missing from the roles entirely -- only admin has it via wildcard) can create records. There is no review step. + +**What to build**: Add `approval_status` and `approved_by_user_id` to `finance_record`. Add `finance.manage` and `finance.view` permissions to relevant roles. Add approval workflow RPCs. + +### 21. [Notifications] No way to send bulk or event-triggered notifications +The `notification` table is write-only from the application side. There is no `createNotification` or `sendBulkNotification` RPC. Notifications are only created as side effects (if even implemented). A coordinator cannot send a message like "boat departing in 2 hours" to all passengers. + +**What to build**: `createNotification` RPC (admin/coordinator only) that creates notifications for one or more users. `sendBulkNotification` for a group (retreat participants, today's boat passengers, all active stays). + +### 22. [Users] No way to invite new users +The users page shows existing users and lets admins edit roles, but there is no invite flow. New users can only join by signing in through auth.js. There is no way to pre-register someone with a role. + +**What to build**: `inviteUser` RPC that creates a user record with email and role in 'invited' status. Send an email (or at minimum create the record so they can sign in). + +--- + +## Nice to Have (Would Improve the Experience) + +### 23. [Dashboard] No today's arrivals/departures widget +The dashboard does not show who is arriving or departing today, which is critical for room turnover planning. + +### 24. [Dashboard] No upcoming tasks widget +The dashboard does not show the user's assigned or claimable tasks for today. + +### 25. [Stays] No direct link from stay to room allocation +A stay exists independently of room allocation. To see which room a guest is in, you have to go to room allocations and search by stay ID. No inline room info on the stays page. + +### 26. [Rooms] No cleaning/turnover tracking +There is no concept of room status (clean, dirty, in-progress) or housekeeping tasks auto-generated on checkout. + +### 27. [Boats] Route names not human-readable +Boat trips display "Route abc123..." (UUID truncation). Routes need a human-readable name (e.g., "Aswan to Village"). No `listBoatRoutes` function exists for the frontend. + +### 28. [Boats] Boat names not shown +Similar to routes -- boats are shown as UUIDs. No `listBoats` function for the frontend. Should show boat name/capacity. + +### 29. [Retreats] No retreat-specific meal planning +Meals can be linked to a retreat via `retreat_id` on `meal_service`, but there is no UI to plan meals for a specific retreat or view retreat meals in the retreat detail page. + +### 30. [Retreats] No notifications to retreat participants +Publishing a retreat does not notify registered participants. There is no mechanism to send updates to a retreat group. + +### 31. [Tasks] Cannot assign tasks to specific roles (only users) +`task_assignment` links to a specific user. There is no way to say "this task is for any kitchen_lead" -- you must know the person. + +### 32. [Profile] No user preferences beyond dietary +No notification preferences, no timezone setting, no language preference. The dietary profile is the only user preference. + +### 33. [Kitchen] No meal feedback mechanism +There is no way for guests to rate or comment on meals. + +### 34. [Stays] No stay extension or modification workflow +Once a stay is created, there is no way to extend dates or modify the stay type without cancelling and recreating. + +### 35. [Audit Log] No filtering +The audit log shows all entries with no ability to filter by table, user, action, or date range. + +--- + +## Data Model Issues + +### finance_record +- Missing `approved_by_user_id` and `approval_status` fields +- No `finance.manage` or `finance.view` permissions in the roles system (only admin via wildcard can access) + +### inventory_item +- No transaction/history table to track quantity changes over time +- No `last_restocked_at` or `supplier` field + +### meal_service +- No `meal_ingredient` table linking meals to inventory items with quantities +- `menu_notes` is free text with no structured recipe/ingredient support + +### task_template +- `recurrence_cron` exists but nothing reads it -- no scheduler generates instances + +### room +- No `cleaning_status` or `last_cleaned_at` field +- No `floor` or `building` field for larger villages + +### stay +- No `room_preference` field (only `rooms.request_preference` permission exists but no schema support) + +### boat_trip +- No `weather_status` or `cancellation_reason` field +- No `max_passengers` or `max_cargo_weight` field on the trip (only on boat, which is a UUID reference) + +### notification +- No `channel` field (in-app vs email vs SMS) +- No `scheduled_at` field for future notifications + +### retreat +- No `budget` or `pricing` fields +- No `retreat_tag` or `category` for filtering retreat types + +### user +- No `invited_by_user_id` or `invited_at` for tracking invitations +- No `last_active_at` for activity monitoring +- No `preferences` JSONB field for user settings + +--- + +## Specific Recommendations (Priority Order) + +### Tier 1 -- Do This Week (unblocks daily operations) + +1. **Wire the dashboard stats**: Create `getDashboardStats` function, update dashboard page. Half-day effort. + +2. **Enable meal creation**: Remove `disabled` from the Create Meal button, add a creation form calling `createMealService`. Half-day effort. + +3. **Wire retreat people and schedule**: Add `listRetreatPersons` and `listRetreatScheduleItems` RPCs. Wire the retreat detail page to call them. Enable the Add Person and Add Schedule Item buttons. 1-day effort. + +4. **Add "Currently Checked In" view**: Add a filtered tab to stays page. 2-hour effort. + +### Tier 2 -- Do This Sprint (high-impact workflows) + +5. **Meal attendance auto-generation**: Build the function that creates attendance records from active stays/retreats for a date. Show headcount and dietary summary per meal. 2-day effort. + +6. **Boat manifest view**: Add a confirmed-passenger manifest to trip detail. 1-day effort. + +7. **Room availability check**: Build the availability query and date-range picker. 1-day effort. + +8. **Finance summary/totals**: Add summary cards to the finance page. 1-day effort. + +9. **Recurring task generation**: Implement the cron-based task generator from templates. 1-day effort. + +### Tier 3 -- Do Next Sprint (important but not blocking) + +10. **Inventory transaction history and stocktake**. 2-day effort. +11. **Low-stock notifications**. Half-day effort. +12. **Bulk notification sending**. 1-day effort. +13. **Finance entity linking UI**. 1-day effort. +14. **Shopping list from menu** (requires meal_ingredient table). 3-day effort. +15. **User invitation flow**. 1-day effort. +16. **Boat route/boat name display** (add list functions + display). Half-day effort. + +--- + +## Summary + +The platform has a solid data model and a comprehensive set of CRUD operations. The core tables, enum types, and permission system are well-designed. However, the gap between "data exists" and "a human can use this daily" is significant in several areas: + +- **Kitchen module** is essentially non-functional from the UI (cannot create meals, no attendance, no dietary aggregation) +- **Retreat detail page** is a shell (people and schedule are hardcoded empty arrays) +- **Dashboard** provides no operational value (all stats are "--") +- **Cross-entity workflows** are missing (menu to shopping list, stay to meal attendance, cancellation to rebooking, checkout to room cleaning) +- **Finance** has no summaries, no approval flow, and no entity linking UI + +The backend functions are about 70% complete. The frontend is about 50% complete relative to what the backend supports. The biggest wins come from wiring existing backend capabilities to the frontend (retreat persons, meal creation) and adding the handful of missing aggregation/summary functions that make the data actionable. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2001a38 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# perauset diff --git a/ai/memory-bank/tasks/perauset-implementation-plan.md b/ai/memory-bank/tasks/perauset-implementation-plan.md new file mode 100644 index 0000000..d9c6b28 --- /dev/null +++ b/ai/memory-bank/tasks/perauset-implementation-plan.md @@ -0,0 +1,1249 @@ +# 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 = ''` 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** | diff --git a/ai/memory-bank/ux-architecture.md b/ai/memory-bank/ux-architecture.md new file mode 100644 index 0000000..1f2effe --- /dev/null +++ b/ai/memory-bank/ux-architecture.md @@ -0,0 +1,1117 @@ +# Perauset Frontend UX Architecture + +Next.js (App Router) + Mantine v8. Backend: Pikku RPC over HTTP (port 6002). + +--- + +## 1. Layout System + +### Shell Structure + +``` +AppShell (Mantine AppShell) ++--------------------------------------------------+ +| Header | +| [Logo/Name] [Search?] [Bell] [ThemeToggle] [Avatar] | ++--------+-----------------------------------------+ +| Sidebar| Main Content Area | +| (nav) | | +| | | +| | | +| | | +| | | ++--------+-----------------------------------------+ +``` + +Use Mantine's `AppShell` with `AppShell.Header`, `AppShell.Navbar`, `AppShell.Main`. + +**Header** (60px): Logo left, notification bell + user avatar right. Bell shows unread count badge. + +**Sidebar** (260px desktop, collapsed on mobile): Icon + label nav items. Collapsible groups per domain. Active item highlighted. On mobile, the sidebar becomes a hamburger-triggered drawer. + +### Component Hierarchy + +``` +app/layout.tsx + AuthProvider (session context) + MantineProvider (theme) + AppShell + AppShell.Header + Logo + HeaderActions (NotificationBell, ThemeToggle, UserMenu) + AppShell.Navbar + SidebarNav (role-filtered) + AppShell.Main + children (page content) +``` + +### Sidebar Navigation — Role-Based Visibility + +Each nav item declares the permission it requires. The sidebar component filters items against the session's resolved permissions. + +```typescript +type NavItem = { + label: string + icon: TablerIcon + href: string + permission?: string // Required permission, omit = visible to all authenticated + children?: NavItem[] // Nested items (collapsible group) + badge?: () => ReactNode // Dynamic badge (e.g., pending count) +} + +const NAV_ITEMS: NavItem[] = [ + { + label: 'Dashboard', + icon: IconDashboard, + href: '/dashboard', + // No permission — visible to everyone + }, + { + label: 'My Requests', + icon: IconSend, + href: '/my-requests', + // Visible to all authenticated users + }, + { + label: 'Stays', + icon: IconBed, + href: '/stays', + permission: 'stays.view_all', + children: [ + { label: 'Requests', href: '/stays/requests', permission: 'stays.review' }, + { label: 'Active Stays', href: '/stays/active', permission: 'stays.view_all' }, + { label: 'Check-in/out', href: '/stays/checkin', permission: 'stays.manage' }, + ], + }, + { + label: 'Rooms', + icon: IconDoor, + href: '/rooms', + permission: 'rooms.view_all', + children: [ + { label: 'Occupancy', href: '/rooms/occupancy', permission: 'rooms.view_all' }, + { label: 'Manage', href: '/rooms/manage', permission: 'rooms.manage' }, + ], + }, + { + label: 'Boats', + icon: IconSailboat, + href: '/boats', + permission: 'boats.view_all', + children: [ + { label: 'Schedule', href: '/boats/schedule', permission: 'boats.view_all' }, + { label: 'Requests', href: '/boats/requests', permission: 'boats.manage' }, + ], + }, + { + label: 'Tasks', + icon: IconChecklist, + href: '/tasks', + permission: 'tasks.view_all', + children: [ + { label: 'Board', href: '/tasks/board', permission: 'tasks.view_all' }, + { label: 'Templates', href: '/tasks/templates', permission: 'tasks.manage' }, + ], + }, + { + label: 'Retreats', + icon: IconMountain, + href: '/retreats', + permission: 'retreats.view', + }, + { + label: 'Kitchen', + icon: IconToolsKitchen2, + href: '/kitchen', + permission: 'kitchen.view', + children: [ + { label: 'Meal Services', href: '/kitchen/meals', permission: 'kitchen.view' }, + { label: 'Dietary Profiles', href: '/kitchen/dietary', permission: 'kitchen.manage' }, + { label: 'My Dietary Info', href: '/kitchen/my-dietary', permission: 'kitchen.update_dietary' }, + ], + }, + { + label: 'Inventory', + icon: IconPackage, + href: '/inventory', + permission: 'inventory.request', + children: [ + { label: 'Items', href: '/inventory/items', permission: 'inventory.manage' }, + { label: 'Requests', href: '/inventory/requests', permission: 'inventory.manage' }, + ], + }, + { + label: 'People', + icon: IconUsers, + href: '/people', + permission: 'users.view', + children: [ + { label: 'Users', href: '/people/users', permission: 'users.view' }, + { label: 'Roles', href: '/people/roles', permission: 'roles.assign' }, + ], + }, + { + label: 'Audit Log', + icon: IconHistory, + href: '/audit', + permission: 'audit_log.view', + }, +] +``` + +**Filtering logic:** + +```typescript +function filterNavItems(items: NavItem[], userPermissions: Set): NavItem[] { + return items + .filter(item => !item.permission || userPermissions.has(item.permission) || userPermissions.has('*')) + .map(item => ({ + ...item, + children: item.children ? filterNavItems(item.children, userPermissions) : undefined, + })) + .filter(item => !item.children || item.children.length > 0) +} +``` + +### Mobile Navigation + +On viewports below 768px: + +- Sidebar collapses entirely. A hamburger icon in the header toggles a Mantine `Drawer` from the left. +- The drawer contains the same nav items, rendered as a flat accordion. +- Header shrinks: logo becomes icon-only, actions collapse into a single menu. +- Bottom tab bar for the 4-5 most-used items: Dashboard, My Requests, Tasks (if permitted), Boats (if permitted), More (opens drawer). + +``` +Mobile layout: ++--------------------------------------------+ +| [=] Logo [Bell] [Avatar] | ++--------------------------------------------+ +| Main Content (full width, scrollable) | +| | ++--------------------------------------------+ +| [Dashboard] [Requests] [Tasks] [More] | <-- BottomNav ++--------------------------------------------+ +``` + +--- + +## 2. Component Patterns + +### 2.1 Request/Approval Cards + +The platform has several request-then-review flows: stay requests, boat seat requests, inventory requests. All follow the same structural pattern. + +**RequestCard component:** + +``` ++-------------------------------------------------------+ +| [StatusBadge] [TimeAgo] | +| | +| Requester Name | +| Request type or summary line | +| | +| Key details (dates, notes, etc.) in a compact grid | +| Start: Mar 20 End: Mar 27 Type: Volunteer | +| | +| --- coordinator-only section (if has review perm) --- | +| [Approve] [Reject] [Review Notes input] | ++-------------------------------------------------------+ +``` + +Props interface: + +```typescript +type RequestCardProps = { + id: string + status: string + entityType: 'stay_request' | 'boat_reservation_request' | 'inventory_request' + requester: { name: string; avatarUrl?: string } + title: string + subtitle?: string + details: { label: string; value: string }[] + notes?: string + createdAt: Date + reviewActions?: { + onApprove: () => void + onReject: () => void + onReviewNotes?: (notes: string) => void + } +} +``` + +Use Mantine `Card` with `Card.Section` dividers. The review actions section renders only when `reviewActions` is provided (which the page component controls based on permission). + +**Request lifecycle view (community user):** + +``` +My Requests page (list of own requests across all types) + - Filterable by type (stay, boat, inventory) + - Filterable by status + - Each card shows status + basic info + - Clicking opens detail drawer/modal +``` + +**Request review view (coordinator):** + +``` +Stays > Requests page (all pending stay requests) + - Default filter: status = submitted | under_review + - Cards show review action buttons inline + - Approve opens a confirmation dialog + - Reject opens a dialog with required rejection reason + - Optimistic update: card moves to approved/rejected state immediately +``` + +### 2.2 Status Badges with Transitions + +Statuses are central to this platform. Every entity has a state machine. Badges must be visually distinct per status family. + +**Color mapping (Mantine color names):** + +```typescript +const STATUS_COLORS: Record = { + // Pending / needs attention + submitted: 'yellow', + under_review: 'orange', + planned: 'gray', + draft: 'gray', + waitlisted: 'orange', + + // Active / in progress + open: 'blue', + active: 'blue', + confirmed: 'teal', + assigned: 'indigo', + in_progress: 'violet', + checked_in: 'teal', + published: 'blue', + approved: 'teal', + + // Completed / success + completed: 'green', + checked_out: 'green', + fulfilled: 'green', + + // Negative / terminal + cancelled: 'red', + rejected: 'red', + declined: 'red', + blocked: 'red', +} +``` + +**StatusBadge component:** + +```typescript +function StatusBadge({ status, entityType }: { status: string; entityType?: string }) { + const color = STATUS_COLORS[status] ?? 'gray' + const label = status.replace(/_/g, ' ') + return {label} +} +``` + +**Transition indicators:** When a coordinator takes an action (approve, reject, check-in), briefly show the old status with a strikethrough fading into the new status. Accomplish this with Mantine's `Transition` component and a simple CSS animation, not a library. + +```typescript +// StatusTransition: shows old -> new with animation +function StatusTransition({ from, to }: { from: string; to: string }) { + return ( + + + + + + ) +} +``` + +### 2.3 Operational Boards + +#### Task Kanban Board + +For coordinators/staff with `tasks.view_all`. Uses columns based on task status. + +``` ++----------+----------+----------+----------+----------+ +| Open | Assigned |In Progress| Blocked | Completed| ++----------+----------+----------+----------+----------+ +| [Card] | [Card] | [Card] | [Card] | [Card] | +| [Card] | [Card] | | | | +| [Card] | | | | | ++----------+----------+----------+----------+----------+ +``` + +Implementation approach: +- Use CSS Grid with horizontal scroll on mobile (snap scrolling). +- Each column is a vertical stack of `TaskCard` components. +- Drag-and-drop is a nice-to-have, NOT a requirement for v1. Use explicit action buttons on each card instead (Assign, Start, Complete, Block). +- Filter bar above: category (housekeeping, kitchen, maintenance...), assigned user, date range. +- On mobile: switch to a list view grouped by status, with collapsible status sections. + +```typescript +type TaskCardProps = { + taskId: string + title: string + category: TaskCategory + status: TaskStatus + assignee?: { name: string } + scheduledAt?: Date + location?: string + actions: TaskAction[] // Derived from state machine transitions +} +``` + +#### Boat Trip Timeline + +For boat coordinators. A day-view timeline showing trips along a time axis. + +``` +Today: March 17, 2026 [< Prev Day] [Next Day >] ++-----+------+------+------+------+------+------+ +| 6am | 8am | 10am | 12pm | 2pm | 4pm | 6pm | ++-----+------+------+------+------+------+------+ +| Route A: Island -> Mainland | +| [08:00 =========> 09:30] MV Seabird (12/15) | +| [14:00 =========> 15:30] MV Seabird (8/15) | ++--------------------------------------------------+ +| Route B: Mainland -> Island | +| [10:00 =========> 11:30] MV Coral (10/20) | ++--------------------------------------------------+ +``` + +Implementation: +- Custom component using CSS Grid where columns represent time slots. +- Each trip is a positioned bar spanning its departure-to-arrival time. +- Bar color indicates status (planned=gray, open=blue, full=orange, completed=green, cancelled=red with strikethrough). +- Clicking a trip bar opens a detail panel/drawer showing manifest, seat requests, actions. +- On mobile: switch to a simple chronological list sorted by departure time. + +### 2.4 Date Range Pickers for Stays/Rooms + +Use Mantine `DatePickerInput` with `type="range"` for selecting stay date ranges. + +**Stay request form:** + +```typescript + +``` + +**Room allocation form (coordinator):** + +Same date range picker, but also displays a mini occupancy preview below showing room availability for the selected range. This is a custom component that queries `listRoomAllocations` for the date range and overlays occupied/available indicators. + +### 2.5 Occupancy Grid for Rooms + +A calendar-style grid showing room occupancy over time. This is the core scheduling view for coordinators. + +``` + Mar 17 Mar 18 Mar 19 Mar 20 Mar 21 Mar 22 ++------------+ +-------+-------+-------+-------+-------+-------+ +| Room A (2) | |[=====John Doe=====] | |[===Jane===] | +| Room B (4) | |[==============Retreat Group==============] | +| Room C (1) | | MAINT | | |[===Bob Smith===] | +| Room D (2) | | | |[====Alice====]| | | ++------------+ +-------+-------+-------+-------+-------+-------+ +``` + +Implementation approach: +- Rows = rooms (sorted by type, then name). Columns = days. +- Each cell can be: empty, occupied (colored bar spanning multiple days), blocked (hatched pattern for maintenance/admin holds). +- Occupied bars show guest name, colored by stay type (volunteer=blue, guest=green, staff=purple, facilitator=teal). +- Clicking an occupied bar opens the stay detail. +- Clicking an empty cell opens the room allocation form pre-filled with that room and date. +- Horizontal scroll with sticky room name column on the left. +- Time range selector above: week view (default), 2-week, month. +- Room type filter: show all, private only, dorm only, etc. + +```typescript +type OccupancyGridProps = { + rooms: Room[] + allocations: RoomAllocation[] + blocks: RoomBlock[] + dateRange: [Date, Date] + onCellClick: (roomId: string, date: Date) => void + onAllocationClick: (allocationId: string) => void +} +``` + +On mobile: the grid is too wide. Switch to a single-room view (select room from dropdown, see its calendar). Or a day-view showing all rooms for one day as a vertical list. + +### 2.6 List/Detail Pattern for CRUD Entities + +Every domain entity (users, rooms, boats, retreats, inventory items, task templates) follows the same list/detail pattern. + +**List page structure:** + +``` ++-----------------------------------------------------+ +| Page Title [+ Create] btn | ++-----------------------------------------------------+ +| Filters bar: | +| [Status v] [Type v] [Search________] [Date range] | ++-----------------------------------------------------+ +| Table or Card list | +| +--------------------------------------------------+| +| | Name/Title | Status | Key Field | Actions || +| +--------------------------------------------------+| +| | Row 1 | Active | ... | [View] [Edit] || +| | Row 2 | Draft | ... | [View] [Edit] || +| +--------------------------------------------------+| +| Pagination: [< 1 2 3 ... 10 >] | ++-----------------------------------------------------+ +``` + +- On desktop: use Mantine `Table` with sortable columns. +- On mobile: switch to card-based layout (each row becomes a card). +- The `[View]` action navigates to a detail page or opens a detail drawer (depending on entity complexity). + +**Detail page structure:** + +``` ++-----------------------------------------------------+ +| [< Back to list] | +| Entity Title [StatusBadge] | +| Created by X on Date | ++-----------------------------------------------------+ +| Tab bar: [Overview] [History] [Related] | ++-----------------------------------------------------+ +| Tab content (scrollable) | +| | +| Overview: Key-value detail grid | +| History: Timeline of status changes (from audit log) | +| Related: Linked entities (e.g., stay -> room alloc) | ++-----------------------------------------------------+ +| Action bar (sticky bottom on mobile): | +| [Primary Action] [Secondary Action] [Cancel/Delete] | ++-----------------------------------------------------+ +``` + +**Reusable components for this pattern:** + +```typescript +// Generic list page wrapper +type EntityListPageProps = { + title: string + createHref?: string + createPermission?: string + filters: FilterConfig[] + columns: ColumnConfig[] // For table view + cardRenderer?: (item: T) => ReactNode // For card view + fetchFn: (params: FilterParams) => Promise<{ items: T[]; total: number }> +} + +// Generic detail page wrapper +type EntityDetailPageProps = { + title: string + backHref: string + status?: string + entityType?: string + tabs: TabConfig[] + actions?: ActionConfig[] +} +``` + +--- + +## 3. State Management + +### Auth Session + +```typescript +// contexts/auth-context.tsx +type AuthState = { + user: { + userId: string + email: string + displayName: string + roles: string[] + } | null + permissions: Set // Resolved from roles via ROLE_PERMISSIONS + isLoading: boolean +} + +const AuthContext = createContext(...) +``` + +Session is loaded on app mount via a `getSession` Pikku call. Stored in React context (not a global store — Mantine and Next.js App Router work well with context). + +**Permission checking hook:** + +```typescript +function usePermission(permission: string): boolean { + const { permissions } = useAuth() + return permissions.has(permission) || permissions.has('*') +} + +function useAnyPermission(...perms: string[]): boolean { + const { permissions } = useAuth() + return permissions.has('*') || perms.some(p => permissions.has(p)) +} +``` + +**Role-based rendering:** + +```typescript +// Wrapper component for permission gating +function RequirePermission({ permission, children, fallback }: { + permission: string + children: ReactNode + fallback?: ReactNode +}) { + const allowed = usePermission(permission) + if (!allowed) return fallback ?? null + return <>{children} +} +``` + +Usage in pages: + +```typescript +// In a page component + + + +``` + +Server-side: use Pikku session from cookies in server components to pre-check permissions and avoid rendering pages the user cannot access. Redirect at the layout/page level, not just hide UI. + +### Optimistic Updates + +For state transitions (approve, reject, check-in, complete, etc.), use optimistic updates: + +```typescript +function useOptimisticTransition( + items: T[], + mutationFn: (id: string) => Promise +) { + const [optimisticItems, setOptimisticItems] = useState(items) + + async function transition(id: string, newStatus: string) { + // Immediately update local state + setOptimisticItems(prev => + prev.map(item => item.id === id ? { ...item, status: newStatus } : item) + ) + try { + await mutationFn(id) + // On success, refetch or keep optimistic state + } catch { + // Rollback on failure + setOptimisticItems(items) + // Show error notification + } + } + + return { items: optimisticItems, transition } +} +``` + +Alternatively, use `@tanstack/react-query` (TanStack Query) for cache management and optimistic updates. This is the recommended approach since it handles cache invalidation, background refetching, and stale-while-revalidate automatically. + +```typescript +// Recommended: TanStack Query pattern +const queryClient = useQueryClient() + +const approveStay = useMutation({ + mutationFn: (requestId: string) => api.approveStayRequest({ requestId }), + onMutate: async (requestId) => { + await queryClient.cancelQueries({ queryKey: ['stayRequests'] }) + const previous = queryClient.getQueryData(['stayRequests']) + queryClient.setQueryData(['stayRequests'], (old) => + old.map(r => r.requestId === requestId ? { ...r, status: 'approved' } : r) + ) + return { previous } + }, + onError: (_err, _id, context) => { + queryClient.setQueryData(['stayRequests'], context.previous) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['stayRequests'] }) + }, +}) +``` + +--- + +## 4. Data Fetching Pattern + +### When to Use Server vs Client + +**Server-side (RSC / SSR) — use for:** +- Initial page loads: list pages, detail pages, dashboard summaries +- Any data that does not change in response to user interaction on the same page +- SEO-irrelevant here (internal app), but SSR still helps with perceived performance +- Auth session resolution (read cookie, call Pikku `getSession` server-side) + +**Client-side (SWR/TanStack Query) — use for:** +- Data that changes after user actions (approve a request, the list updates) +- Polling / real-time data (notification count, task board) +- Infinite scroll / pagination interactions +- Form submissions and their responses +- Any data dependent on client-side filter/search state + +### Pikku RPC Integration + +Pikku functions are called via HTTP. Create a thin client wrapper: + +```typescript +// lib/api.ts +import { createPikkuClient } from '@pikku/client' // Or a thin fetch wrapper + +const api = createPikkuClient({ + baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:6002/api', +}) + +// Server-side variant (includes cookie forwarding) +export function createServerApi(cookies: ReadonlyHeaders) { + return createPikkuClient({ + baseUrl: process.env.API_URL ?? 'http://localhost:6002/api', + headers: { cookie: cookies.get('cookie')?.value ?? '' }, + }) +} +``` + +**Server component example:** + +```typescript +// app/stays/requests/page.tsx (Server Component) +import { createServerApi } from '@/lib/api' +import { cookies } from 'next/headers' + +export default async function StayRequestsPage() { + const api = createServerApi(await cookies()) + const { items } = await api.listStayRequests({ limit: 50, offset: 0 }) + return +} +``` + +**Client component with TanStack Query:** + +```typescript +// components/stays/StayRequestsList.tsx +'use client' + +function StayRequestsList({ initialData }: { initialData: StayRequest[] }) { + const { data } = useQuery({ + queryKey: ['stayRequests', filters], + queryFn: () => api.listStayRequests(filters), + initialData: { items: initialData }, + staleTime: 30_000, // 30s before refetch + }) + + return /* render list with data.items */ +} +``` + +### Query Key Convention + +Consistent query keys for cache management: + +``` +['stayRequests', { status, limit, offset }] +['stays', { status, limit, offset }] +['stay', stayId] +['rooms'] +['roomAllocations', { startDate, endDate }] +['boatTrips', { date }] +['boatRequests', { tripId }] +['tasks', { status, category }] +['retreats', { status }] +['retreat', retreatId] +['notifications', { limit, offset }] +['notificationCount'] // Unread count, polled frequently +['inventory'] +['inventoryRequests', { status }] +['users', { search }] +['mealServices', { date }] +``` + +--- + +## 5. Mobile and Offline Considerations + +The island has limited and intermittent connectivity. This shapes the architecture significantly. + +### Connectivity Tiers + +1. **Online**: Full functionality, real-time updates. +2. **Slow/intermittent**: App works, but requests may be slow. Show loading states, avoid blocking the UI. +3. **Offline**: Read cached data, queue write actions for sync when back online. + +### What Should Work Offline / Cached + +**Must cache (service worker + TanStack Query persistence):** +- Current user session and permissions (avoid auth failures offline) +- Today's boat schedule (critical for island operations) +- Active task list for the current user +- Room occupancy for the current week +- Own stay details and dates +- Notification history (already fetched) +- Dietary profiles (kitchen needs this regardless of connectivity) + +**Queue for sync when online:** +- Task status updates (claim, start, complete) — most common offline action +- Meal attendance marking (kitchen staff marking who showed up) +- Check-in/check-out (coordinator at the dock with no signal) + +### Implementation + +```typescript +// Offline queue using IndexedDB (via idb-keyval or Dexie) +type QueuedAction = { + id: string + endpoint: string + method: string + payload: unknown + createdAt: Date + retries: number +} + +// On mutation when offline: +if (!navigator.onLine) { + await offlineQueue.add({ + id: crypto.randomUUID(), + endpoint: '/stays/check-in', + method: 'POST', + payload: { stayId }, + createdAt: new Date(), + retries: 0, + }) + // Update local cache optimistically + // Show "queued" indicator on the item + return +} +``` + +**Sync indicator in the header:** + +``` +[Online] — green dot, hidden after 3s +[Syncing (3)] — orange dot + count of queued actions +[Offline] — red dot, persistent +``` + +### TanStack Query Persistence + +Use `@tanstack/query-persist-client` with an IndexedDB persister to survive page reloads and brief offline periods: + +```typescript +const persister = createIDBPersister('perauset-cache') + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24, // 24h garbage collection + staleTime: 1000 * 30, // 30s stale time + retry: 3, + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), + }, + }, +}) +``` + +### Mobile-Specific Adaptations + +| Desktop pattern | Mobile adaptation | +|---|---| +| Sidebar nav | Bottom tab bar + hamburger drawer | +| Occupancy grid | Single-room calendar or day-view list | +| Task kanban board | Status-grouped list with collapsible sections | +| Boat timeline | Chronological trip list | +| Table views | Card-based layouts | +| Detail in page | Full-screen detail with back gesture | +| Multi-column forms | Single-column stacked form | +| Hover tooltips | Long-press or inline text | + +--- + +## 6. Notification UX + +### Bell Icon with Unread Count + +``` +Header: + ... [Bell(3)] [Avatar] ... +``` + +The bell icon lives in the header, always visible. The unread count badge uses Mantine `Indicator`. + +```typescript +function NotificationBell() { + const { data: count } = useQuery({ + queryKey: ['notificationCount'], + queryFn: () => api.listNotifications({ limit: 1, offset: 0 }) + .then(res => res.items.filter(n => !n.readAt).length), + refetchInterval: 30_000, // Poll every 30s when online + refetchIntervalInBackground: false, + }) + + // Better: dedicated unread-count endpoint to avoid fetching full list + // For now, derive from list query cached data + + return ( + 0 ? count : undefined} + size={16} + disabled={!count || count === 0} + color="red" + > + + + + + ) +} +``` + +### Notification Panel + +Clicking the bell opens a dropdown panel (Mantine `Popover` on desktop, full-screen drawer on mobile). + +``` ++------------------------------------------+ +| Notifications [Mark all read]| ++------------------------------------------+ +| * Stay request approved | <- unread (bold, blue dot) +| Your stay Mar 20-27 was approved | +| 2 hours ago | ++------------------------------------------+ +| Boat trip tomorrow | <- read (normal weight) +| MV Seabird departs 08:00 | +| 1 day ago | ++------------------------------------------+ +| Task assigned to you | +| Kitchen cleanup - Building A | +| 2 days ago | ++------------------------------------------+ +| [View all notifications] | ++------------------------------------------+ +``` + +**Behavior:** +- Clicking a notification marks it as read (call `markNotificationRead`) and navigates to the related entity if `entityType` and `entityId` are present. +- Navigation mapping from notification: + +```typescript +function getNotificationHref(entityType: string | null, entityId: string | null): string | null { + if (!entityType || !entityId) return null + const routes: Record = { + stay_request: `/stays/requests/${entityId}`, + stay: `/stays/${entityId}`, + boat_trip: `/boats/schedule/${entityId}`, + boat_reservation_request: `/boats/requests/${entityId}`, + task_instance: `/tasks/${entityId}`, + retreat: `/retreats/${entityId}`, + inventory_request: `/inventory/requests/${entityId}`, + room_allocation: `/rooms/occupancy`, + } + return routes[entityType] ?? null +} +``` + +- "Mark all read" calls `markNotificationRead` for each unread notification (batch endpoint would be a good backend addition). +- The panel shows the 10 most recent. "View all" navigates to `/notifications` which is a full paginated list. + +### Notification Types and Display + +Map notification `type` field to icons and colors: + +```typescript +const NOTIFICATION_CONFIG: Record = { + stay_request_submitted: { icon: IconBed, color: 'blue' }, + stay_request_approved: { icon: IconCheck, color: 'green' }, + stay_request_rejected: { icon: IconX, color: 'red' }, + stay_checked_in: { icon: IconLogin, color: 'teal' }, + boat_trip_reminder: { icon: IconSailboat, color: 'blue' }, + boat_request_confirmed: { icon: IconCheck, color: 'green' }, + boat_request_declined: { icon: IconX, color: 'red' }, + task_assigned: { icon: IconChecklist, color: 'violet' }, + task_completed: { icon: IconCheck, color: 'green' }, + retreat_published: { icon: IconMountain, color: 'blue' }, + inventory_request_approved: { icon: IconPackage, color: 'green' }, + inventory_request_fulfilled: { icon: IconCheck, color: 'green' }, + generic: { icon: IconBell, color: 'gray' }, +} +``` + +--- + +## 7. Page Route Map + +``` +/ -> Redirect to /dashboard +/login -> Login page (public) + +/dashboard -> Role-adaptive dashboard +/my-requests -> User's own requests (stays, boats, inventory) +/notifications -> Full notification list + +/stays + /stays/requests -> Stay request list (coordinator: review queue) + /stays/requests/new -> New stay request form + /stays/requests/[id] -> Stay request detail + /stays/active -> Active stays list + /stays/[id] -> Stay detail (check-in/out actions) + /stays/checkin -> Quick check-in/out interface + +/rooms + /rooms/occupancy -> Occupancy grid (calendar view) + /rooms/manage -> Room list (CRUD) + /rooms/[id] -> Room detail + +/boats + /boats/schedule -> Boat trip timeline (day view) + /boats/schedule/[id] -> Trip detail (manifest, requests) + /boats/requests -> Seat request review queue + /boats/request/new -> Request a seat form + /boats/manage -> Boats + routes CRUD + +/tasks + /tasks/board -> Kanban board + /tasks/[id] -> Task detail + /tasks/templates -> Task template management + /tasks/claim -> Claimable tasks (community view) + +/retreats + /retreats -> Retreat list + /retreats/new -> Create retreat + /retreats/[id] -> Retreat detail (people, schedule tabs) + +/kitchen + /kitchen/meals -> Meal service calendar + /kitchen/meals/[id] -> Meal detail (attendance, dietary summary) + /kitchen/dietary -> All dietary profiles (kitchen lead view) + /kitchen/my-dietary -> Own dietary profile (self-service) + +/inventory + /inventory/items -> Item list with quantities + /inventory/requests -> Request review queue + /inventory/request/new -> Submit inventory request + +/people + /people/users -> User directory + /people/users/[id] -> User detail + role management + /people/roles -> Role assignment interface + +/audit -> Audit log viewer +``` + +--- + +## 8. Dashboard Design + +The dashboard is role-adaptive. Each widget checks permissions before rendering. + +**Coordinator/Admin dashboard:** + +``` ++---------------------------+---------------------------+ +| Today's Overview | Pending Actions | +| - 12 active stays | - 3 stay requests | +| - 8 rooms occupied (60%) | - 5 boat seat requests | +| - 2 boat trips scheduled | - 2 inventory requests | +| - 4 tasks in progress | | ++---------------------------+---------------------------+ +| Today's Boat Schedule | Task Board Summary | +| 08:00 -> Mainland (12/15) | Open: 3 Assigned: 5 | +| 14:00 <- Mainland (8/20) | In Progress: 4 Blocked: 1| ++---------------------------+---------------------------+ +| This Week's Arrivals/Departures | +| Mar 17: 2 arrivals, 1 departure | +| Mar 18: 0 arrivals, 3 departures | +| ... | ++-------------------------------------------------------+ +``` + +**Community member dashboard:** + +``` ++---------------------------+---------------------------+ +| My Stay | Upcoming Boats | +| Mar 20-27 (Volunteer) | Mar 20 08:00 -> Island | +| Status: Confirmed | Seat: Confirmed | +| Room: Building A, Room 3 | | ++---------------------------+---------------------------+ +| My Tasks | Notifications | +| Kitchen cleanup (today) | Stay approved (2h ago) | +| Garden maintenance (tmrw) | Task assigned (1d ago) | ++---------------------------+---------------------------+ +| Upcoming Retreats | +| Spring Meditation - Mar 25-30 (Published) | ++-------------------------------------------------------+ +``` + +--- + +## 9. Mantine v8 Component Mapping + +Key Mantine components used per pattern: + +| Pattern | Mantine Components | +|---|---| +| Shell layout | `AppShell`, `AppShell.Header`, `AppShell.Navbar`, `AppShell.Main`, `Burger` | +| Navigation | `NavLink`, `ScrollArea`, `Drawer` (mobile) | +| Request cards | `Card`, `Card.Section`, `Badge`, `Group`, `Stack`, `Text`, `Button` | +| Status badges | `Badge` with `variant="light"` | +| Data tables | `Table`, `Table.Thead`, `Table.Tr`, etc. (native Mantine, or `mantine-datatable`) | +| Filters | `Select`, `MultiSelect`, `TextInput`, `DatePickerInput` | +| Forms | `TextInput`, `Textarea`, `Select`, `NumberInput`, `DatePickerInput`, `Checkbox` | +| Task board | `SimpleGrid`, `Card`, `ScrollArea` | +| Detail pages | `Tabs`, `Stack`, `Group`, `Paper`, `Timeline` | +| Modals/drawers | `Modal`, `Drawer` | +| Notifications | `Popover`, `Indicator`, `ActionIcon`, `ScrollArea` | +| Occupancy grid | Custom CSS Grid + `Tooltip`, `Paper` | +| Theme toggle | `SegmentedControl` or `ActionIcon` with system/light/dark | +| Loading | `Skeleton`, `LoadingOverlay`, `Progress` | +| Empty states | `Center`, `Stack`, `Text`, `ThemeIcon` | + +--- + +## 10. Theme System + +Use Mantine's built-in theme system with `useComputedColorScheme` and `useMantineColorScheme`. + +```typescript +// components/ThemeToggle.tsx +function ThemeToggle() { + const { setColorScheme, colorScheme } = useMantineColorScheme() + + return ( + setColorScheme(value as 'light' | 'dark' | 'auto')} + data={[ + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, + { label: 'Auto', value: 'auto' }, + ]} + /> + ) +} +``` + +Mantine v8 handles light/dark natively via CSS variables. No custom CSS variable system needed beyond Mantine's theme configuration in `MantineProvider`. + +Custom theme tokens go in the Mantine `createTheme` call: + +```typescript +const theme = createTheme({ + primaryColor: 'teal', + fontFamily: 'Inter, system-ui, sans-serif', + headings: { fontFamily: 'Inter, system-ui, sans-serif' }, + defaultRadius: 'md', + other: { + // App-specific tokens + statusColors: STATUS_COLORS, + }, +}) +``` diff --git a/apps/app/messages/de.json b/apps/app/messages/de.json new file mode 100644 index 0000000..99c57a3 --- /dev/null +++ b/apps/app/messages/de.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "brand": "PerAuset", + "common_email": "E-Mail", + "common_password": "Passwort", + "login_subtitle": "Melden Sie sich bei Ihrem Konto an", + "login_cta": "Anmelden", + "login_invalid_credentials": "Ungültige E-Mail oder Passwort", + "login_error": "Anmeldung fehlgeschlagen", + "nav_dashboard": "Übersicht", + "nav_stays": "Meine Aufenthalte", + "nav_tasks": "Meine Aufgaben", + "nav_kitchen": "Küche", + "nav_inventory": "Inventar", + "nav_retreats": "Retreats", + "nav_finance": "Finanzen", + "nav_rooms": "Zimmer", + "nav_boats": "Boote", + "nav_users": "Benutzer", + "nav_audit_log": "Audit-Protokoll", + "nav_sign_out": "Abmelden", + "dashboard_title": "Übersicht", + "dashboard_stays": "Aufenthalte", + "dashboard_tasks": "Aufgaben", + "dashboard_boats": "Boote", + "dashboard_retreats": "Retreats" +} diff --git a/apps/app/messages/en.json b/apps/app/messages/en.json new file mode 100644 index 0000000..928d3e1 --- /dev/null +++ b/apps/app/messages/en.json @@ -0,0 +1,612 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "ai_chat_greeting": "How can I help you today?", + "ai_chat_input_placeholder": "Ask me anything about the village...", + "ai_chat_subtitle": "Ask me anything about the village -- stays, tasks, boats, retreats, and more.", + "ai_chat_title": "PerAuset Assistant", + "audit_action": "Action", + "audit_changed_fields": "Changed Fields", + "audit_description": "View a record of all changes made in the system.", + "audit_record_id": "Record ID", + "audit_table": "Table", + "audit_timestamp": "Timestamp", + "audit_title": "Audit Log", + "audit_user": "User", + "boats_accessibility_needs": "Any accessibility or special needs...", + "boats_actions": "Actions", + "boats_active": "Active", + "boats_add_boat": "Add Boat", + "boats_add_route": "Add Route", + "boats_additional_information": "Any additional information...", + "boats_arrival": "Arrival", + "boats_assigned": "Assigned", + "boats_boat": "Boat", + "boats_boat_name": "Boat Name", + "boats_boat_name_placeholder": "e.g. Nile Star", + "boats_boat_notes_placeholder": "Additional details about the boat...", + "boats_boats": "Boats", + "boats_cancel_trip": "Cancel Trip", + "boats_cargo": "Cargo", + "boats_cargo_capacity": "Cargo Capacity (kg)", + "boats_cargo_capacity_placeholder": "e.g. 500", + "boats_cargo_kg": "Cargo (kg)", + "boats_cargo_notes": "Cargo Notes", + "boats_complete_trip": "Complete Trip", + "boats_confirm": "Confirm", + "boats_create_trip": "Create Trip", + "boats_decline": "Decline", + "boats_departure": "Departure", + "boats_describe_cargo": "Describe any cargo you need to bring...", + "boats_destination": "Destination", + "boats_destination_placeholder": "e.g. Aswan", + "boats_edit_boat": "Edit Boat", + "boats_edit_route": "Edit Route", + "boats_fleet_description": "Manage boats and routes.", + "boats_fleet_management": "Fleet Management", + "boats_inactive": "Inactive", + "boats_manifest": "Manifest", + "boats_name": "Name", + "boats_no_boats": "No boats found. Add one to get started.", + "boats_no_boats_available": "No boats available", + "boats_no_confirmed_passengers": "No confirmed passengers yet.", + "boats_no_matching_trips": "No matching trips", + "boats_no_open_trips": "No open trips available", + "boats_no_routes": "No routes found. Add one to get started.", + "boats_no_routes_available": "No routes available", + "boats_no_trips": "No boat trips found.", + "boats_notes": "Notes", + "boats_number_of_seats": "Number of Seats", + "boats_open_for_requests": "Open for Requests", + "boats_origin": "Origin", + "boats_origin_placeholder": "e.g. Luxor", + "boats_passenger_capacity": "Passenger Capacity", + "boats_passengers": "Passengers", + "boats_print_manifest": "Print Manifest", + "boats_request_seat": "Request Seat", + "boats_request_seat_description": "Request a seat on an upcoming boat trip.", + "boats_request_seat_title": "Request Boat Seat", + "boats_requested": "Requested", + "boats_route": "Route", + "boats_route_name": "Route Name", + "boats_route_name_placeholder": "e.g. Luxor Express", + "boats_routes": "Routes", + "boats_save_boat": "Save Boat", + "boats_save_route": "Save Route", + "boats_scheduled_arrival": "Scheduled Arrival", + "boats_scheduled_departure": "Scheduled Departure", + "boats_seat_requests": "Seat Requests", + "boats_seats": "Seats", + "boats_select_boat_optional": "Select a boat (optional)", + "boats_select_route": "Select a route", + "boats_select_trip": "Select a trip", + "boats_special_requirements": "Special Requirements", + "boats_status": "Status", + "boats_trip": "Trip", + "boats_trip_details": "Trip Details", + "boats_trip_not_found": "Trip Not Found", + "boats_trip_not_found_message": "This boat trip could not be found.", + "boats_trips_description": "View upcoming and past boat trips.", + "boats_trips_title": "Boat Trips", + "boats_unknown_boat": "Unknown Boat", + "boats_user": "User", + "brand": "PerAuset", + "common_cancel": "Cancel", + "common_close": "Close", + "common_email": "Email", + "common_error": "Error", + "common_password": "Password", + "common_save_changes": "Save Changes", + "common_success": "Success", + "common_validation": "Validation", + "dashboard_boats": "Boats", + "dashboard_retreats": "Retreats", + "dashboard_stays": "Stays", + "dashboard_tasks": "Tasks", + "dashboard_title": "Dashboard", + "finance_actions": "Actions", + "finance_add_link": "Add New Link", + "finance_add_record": "Add Record", + "finance_amount": "Amount", + "finance_base": "Base", + "finance_by_category": "By Category", + "finance_category": "Category", + "finance_create_record": "Create Record", + "finance_currency": "Currency", + "finance_date": "Date", + "finance_description": "Description", + "finance_edit_record": "Edit Finance Record", + "finance_empty": "No finance records found.", + "finance_entity_type": "Entity Type", + "finance_exchange_rates": "Exchange Rates", + "finance_existing_links": "Existing Links", + "finance_last_updated": "Last updated", + "finance_link_entity": "Link Entity", + "finance_name": "Name", + "finance_net": "Net", + "finance_no_links": "No linked entities yet.", + "finance_notes": "Notes", + "finance_reference": "Reference", + "finance_reference_id": "Reference ID", + "finance_refresh_rates": "Refresh Rates", + "finance_save_changes": "Save Changes", + "finance_subtitle": "Track expenses, income, and reimbursements.", + "finance_title": "Finance", + "finance_total_expenses": "Total Expenses", + "finance_total_income": "Total Income", + "finance_type": "Type", + "inventory_add_item": "Add Item", + "inventory_add_item_title": "Add Inventory Item", + "inventory_alert_threshold": "Alert threshold", + "inventory_cancel": "Cancel", + "inventory_cat_equipment": "Equipment", + "inventory_cat_garden": "Garden", + "inventory_cat_kitchen": "Kitchen", + "inventory_cat_other": "Other", + "inventory_cat_rooms": "Rooms", + "inventory_category": "Category", + "inventory_col_actions": "Actions", + "inventory_col_change": "Change", + "inventory_col_date": "Date", + "inventory_col_item": "Item", + "inventory_col_notes": "Notes", + "inventory_col_quantity": "Quantity", + "inventory_col_reason": "Reason", + "inventory_col_status": "Status", + "inventory_col_unit": "Unit", + "inventory_create_item": "Create Item", + "inventory_current_quantity": "Current Quantity", + "inventory_description": "Track supplies and request items.", + "inventory_edit_item": "Edit Item", + "inventory_edit_item_title": "Edit Inventory Item", + "inventory_error_create": "Failed to create item.", + "inventory_error_request": "Failed to submit request.", + "inventory_error_stocktake": "Failed to submit stocktake.", + "inventory_error_title": "Error", + "inventory_error_update": "Failed to update item.", + "inventory_field_name": "Item Name", + "inventory_field_unit": "Unit", + "inventory_history_title": "History: {name}", + "inventory_in_stock": "In Stock", + "inventory_item_created_message": "Inventory item has been added.", + "inventory_item_created_title": "Item created", + "inventory_item_updated_message": "Inventory item has been updated.", + "inventory_item_updated_title": "Item updated", + "inventory_low_stock": "Low Stock", + "inventory_minimum_quantity": "Minimum Quantity", + "inventory_missing_fields_message": "Name and unit are required.", + "inventory_missing_fields_title": "Missing fields", + "inventory_name_placeholder": "e.g. Rice, Soap, Candles", + "inventory_no_history": "No transaction history for this item.", + "inventory_no_items": "No inventory items found.", + "inventory_request_item": "Request Item", + "inventory_request_item_title": "Request Inventory Item", + "inventory_request_name_placeholder": "e.g., Olive oil, Cleaning supplies", + "inventory_request_purpose": "Purpose", + "inventory_request_purpose_placeholder": "What is this for?", + "inventory_request_quantity": "Quantity", + "inventory_request_quantity_placeholder": "Amount needed", + "inventory_request_submitted_message": "Your inventory request has been submitted.", + "inventory_request_submitted_title": "Request submitted", + "inventory_request_unit_placeholder": "e.g., liters, kg, boxes", + "inventory_save_changes": "Save Changes", + "inventory_stocktake": "Stocktake", + "inventory_stocktake_current": "Current: {quantity} {unit}", + "inventory_stocktake_intro": "Enter the actual counted quantity for each item. Only changed items will be updated.", + "inventory_stocktake_submitted_message": "{count} item(s) adjusted.", + "inventory_stocktake_submitted_title": "Stocktake submitted", + "inventory_stocktake_title": "Stocktake", + "inventory_submit_request": "Submit Request", + "inventory_submit_stocktake": "Submit Stocktake", + "inventory_tab_all": "All ({count})", + "inventory_tab_equipment": "Equipment ({count})", + "inventory_tab_garden": "Garden ({count})", + "inventory_tab_kitchen": "Kitchen ({count})", + "inventory_tab_other": "Other ({count})", + "inventory_tab_rooms": "Rooms ({count})", + "inventory_title": "Inventory", + "inventory_transaction_history": "Transaction History", + "inventory_unit_placeholder": "e.g. kg, liters, pieces", + "kitchen_cancel": "Cancel", + "kitchen_create_meal": "Create Meal", + "kitchen_create_meal_service": "Create Meal Service", + "kitchen_description": "Meal services and dietary management.", + "kitchen_dietary_allergy_notes": "Allergy Notes", + "kitchen_dietary_allergy_notes_placeholder": "Describe any allergies in detail...", + "kitchen_dietary_cancel": "Cancel", + "kitchen_dietary_dairy_free": "Dairy-free", + "kitchen_dietary_description": "Update your dietary preferences and allergies so the kitchen can accommodate your needs.", + "kitchen_dietary_dislikes": "Dislikes", + "kitchen_dietary_dislikes_placeholder": "Foods you prefer to avoid...", + "kitchen_dietary_error": "Failed to save dietary profile", + "kitchen_dietary_gluten_free": "Gluten-free", + "kitchen_dietary_nut_allergy": "Nut allergy", + "kitchen_dietary_other_requirements": "Other Requirements", + "kitchen_dietary_other_requirements_placeholder": "Any other dietary requirements...", + "kitchen_dietary_preferences": "Dietary Preferences", + "kitchen_dietary_save": "Save Profile", + "kitchen_dietary_success": "Dietary profile saved successfully.", + "kitchen_dietary_title": "Dietary Profile", + "kitchen_dietary_vegan": "Vegan", + "kitchen_dietary_vegetarian": "Vegetarian", + "kitchen_error_create_meal": "Failed to create meal service.", + "kitchen_error_title": "Error", + "kitchen_expected_headcount": "Expected headcount: {count}", + "kitchen_meal_created_message": "The meal service has been created.", + "kitchen_meal_created_title": "Meal created", + "kitchen_meal_type_breakfast": "Breakfast", + "kitchen_meal_type_dinner": "Dinner", + "kitchen_meal_type_lunch": "Lunch", + "kitchen_meal_type_snack": "Snack", + "kitchen_menu_notes": "Menu Notes", + "kitchen_menu_notes_placeholder": "Menu description or notes", + "kitchen_no_meals": "No meal services scheduled.", + "kitchen_planned_headcount": "Planned Headcount", + "kitchen_planned_headcount_placeholder": "Expected number of diners", + "kitchen_select_meal_type": "Select meal type", + "kitchen_service_date": "Service Date", + "kitchen_service_type": "Service Type", + "kitchen_status_cancelled": "Cancelled", + "kitchen_status_planned": "Planned", + "kitchen_status_preparing": "Preparing", + "kitchen_status_served": "Served", + "kitchen_title": "Kitchen", + "login_cta": "Sign in", + "login_error": "Sign-in failed", + "login_invalid_credentials": "Invalid email or password", + "login_subtitle": "Sign in to your account", + "my_active_stays": "Active Stays", + "my_dietary_profile": "Dietary Profile", + "my_dietary_profile_description": "Manage your dietary preferences, allergies, and meal requirements.", + "my_display_name": "Display Name", + "my_display_name_placeholder": "How should we call you?", + "my_edit_profile": "Edit Profile", + "my_field_email": "Email", + "my_field_name": "Name", + "my_field_roles": "Roles", + "my_field_status": "Status", + "my_first_name": "First Name", + "my_first_name_placeholder": "First name", + "my_last_name": "Last Name", + "my_last_name_placeholder": "Last name", + "my_mobile_number": "Mobile Number", + "my_no_active_stays": "You don't have any active stays at the moment.", + "my_no_active_tasks": "No active tasks right now. Enjoy the calm!", + "my_no_requests": "You haven't submitted any stay requests yet.", + "my_phone": "Phone", + "my_phone_placeholder": "+1 (555) 000-0000", + "my_profile_description": "View and update your personal information.", + "my_profile_title": "My Profile", + "my_requests": "My Requests", + "my_save_profile": "Save Profile", + "my_sign_in_to_view": "Please sign in to view your profile.", + "my_stays_description": "Your stay requests and active stays.", + "my_stays_title": "My Stays", + "my_tasks_assigned": "Assigned to Me", + "my_tasks_blocked": "Blocked", + "my_tasks_description": "Tasks assigned to you or available to claim.", + "my_tasks_in_progress": "In Progress", + "my_tasks_open": "Open (Claimable)", + "my_tasks_title": "My Tasks", + "my_update_failed": "Failed to update profile", + "my_update_success": "Profile updated successfully.", + "my_whatsapp_number": "WhatsApp Number", + "nav_audit_log": "Audit Log", + "nav_boat_management": "Boat Management", + "nav_boats": "Boats", + "nav_dashboard": "Dashboard", + "nav_finance": "Finance", + "nav_inventory": "Inventory", + "nav_kitchen": "Kitchen", + "nav_management": "Management", + "nav_more": "More", + "nav_my_profile": "My Profile", + "nav_my_stays": "My Stays", + "nav_my_tasks": "My Tasks", + "nav_operations": "Operations", + "nav_personal": "Personal", + "nav_profile": "Profile", + "nav_retreats": "Retreats", + "nav_rooms": "Rooms", + "nav_sign_out": "Sign out", + "nav_stays": "My Stays", + "nav_tasks": "My Tasks", + "nav_users": "Users", + "notifications_all_caught_up": "You are all caught up.", + "notifications_empty": "No notifications", + "notifications_title": "Notifications", + "notifications_unread_count": "{count} unread", + "notifications_view_all": "View all notifications", + "profile_description": "View and update your personal information.", + "profile_dietary_profile": "Dietary Profile", + "profile_dietary_profile_description": "Manage your dietary preferences and allergies.", + "profile_display_name": "Display Name", + "profile_display_name_placeholder": "How should we call you?", + "profile_edit_dietary_profile": "Edit Dietary Profile", + "profile_edit_profile": "Edit Profile", + "profile_field_email": "Email", + "profile_field_roles": "Roles", + "profile_field_status": "Status", + "profile_first_name": "First Name", + "profile_first_name_placeholder": "First name", + "profile_last_name": "Last Name", + "profile_last_name_placeholder": "Last name", + "profile_phone": "Phone", + "profile_phone_placeholder": "+1 (555) 000-0000", + "profile_save_profile": "Save Profile", + "profile_sign_in_to_view": "Please sign in to view your profile.", + "profile_title": "My Profile", + "profile_update_failed": "Failed to update profile", + "profile_update_success": "Profile updated successfully.", + "retreats_add_meal": "Add Meal", + "retreats_add_meal_service": "Add Meal Service", + "retreats_add_person": "Add Person", + "retreats_add_person_title": "Add Person to Retreat", + "retreats_add_schedule_item": "Add Schedule Item", + "retreats_back": "Back to Retreats", + "retreats_capacity": "Capacity", + "retreats_create": "Create Retreat", + "retreats_description": "Description", + "retreats_empty": "No retreats found. Create one to get started.", + "retreats_end_date": "End Date", + "retreats_ends_at": "Ends At", + "retreats_headcount": "Headcount", + "retreats_lead_facilitator": "Lead Facilitator", + "retreats_location": "Location", + "retreats_menu_notes": "Menu Notes", + "retreats_name": "Retreat Name", + "retreats_no_meals": "No meal services planned for this retreat yet.", + "retreats_no_people": "No people added to this retreat yet.", + "retreats_no_schedule": "No schedule items yet.", + "retreats_not_found_body": "The retreat you are looking for does not exist or you do not have access.", + "retreats_not_found_title": "Retreat Not Found", + "retreats_notify_body": "Body", + "retreats_notify_participants": "Notify Participants", + "retreats_notify_subject": "Title", + "retreats_notify_title": "Notify Retreat Participants", + "retreats_person_name": "Name", + "retreats_planned_headcount": "Planned Headcount", + "retreats_role": "Role", + "retreats_schedule_title": "Title", + "retreats_send_notification": "Send Notification", + "retreats_service_date": "Service Date", + "retreats_service_type": "Service Type", + "retreats_slug": "Slug", + "retreats_start_date": "Start Date", + "retreats_starts_at": "Starts At", + "retreats_status": "Status", + "retreats_subtitle": "Manage and view scheduled retreats.", + "retreats_tab_meals": "Meals", + "retreats_tab_overview": "Overview", + "retreats_tab_people": "People", + "retreats_tab_schedule": "Schedule", + "retreats_title": "Retreats", + "rooms_active": "Active", + "rooms_allocations_all_description": "All room allocations across the village.", + "rooms_allocations_room_description": "Allocations for this room.", + "rooms_allocations_title": "Room Allocations", + "rooms_availability": "Availability", + "rooms_availability_end": "Available To", + "rooms_availability_start": "Available From", + "rooms_available": "Available", + "rooms_capacity": "Capacity", + "rooms_create_room": "Create Room", + "rooms_dates": "Dates", + "rooms_description": "View and manage room inventory.", + "rooms_edit_room": "Edit Room", + "rooms_full": "Full", + "rooms_inactive": "Inactive", + "rooms_no_rooms": "No rooms found.", + "rooms_notes": "Notes", + "rooms_notes_placeholder": "Additional details...", + "rooms_price_night": "Price / Night", + "rooms_price_per_night": "Price per Night (EUR)", + "rooms_price_placeholder": "e.g. 25.00", + "rooms_reason": "Reason", + "rooms_room": "Room", + "rooms_room_code": "Room Code", + "rooms_room_code_placeholder": "e.g. R101", + "rooms_room_name": "Room Name", + "rooms_room_name_placeholder": "e.g. Sunrise Room", + "rooms_room_type": "Room Type", + "rooms_save_room": "Save Room", + "rooms_select_type": "Select type", + "rooms_showing_availability": "Showing availability", + "rooms_status": "Status", + "rooms_title": "Rooms", + "rooms_type": "Type", + "rooms_view_allocations": "View Allocations", + "stays_actions": "Actions", + "stays_active_stays": "Active Stays", + "stays_approve": "Approve", + "stays_approve_failed": "Failed to approve request.", + "stays_cancel_failed": "Failed to cancel stay.", + "stays_cancel_stay": "Cancel Stay", + "stays_check_in": "Check In", + "stays_check_in_date": "Check-in Date", + "stays_check_in_failed": "Failed to check in.", + "stays_check_out": "Check Out", + "stays_check_out_failed": "Failed to check out.", + "stays_checked_in": "Checked In", + "stays_checked_in_message": "Guest has been checked in.", + "stays_checked_out": "Checked out", + "stays_checked_out_message": "Guest has been checked out.", + "stays_create_failed": "Something went wrong creating the stay.", + "stays_create_stay": "Create Stay", + "stays_create_stay_hint": "This will create a stay request, auto-approve it, and generate the stay record in one step. The stay will be ready for check-in immediately.", + "stays_dates": "Dates", + "stays_description": "Manage stays, requests, and check-ins.", + "stays_end_date": "End Date", + "stays_expected_checkout": "Expected Checkout", + "stays_guest": "Guest", + "stays_missing_fields": "Missing fields", + "stays_missing_fields_message": "Please fill in all required fields.", + "stays_no_active_stays": "No active stays found.", + "stays_no_checked_in": "No guests currently checked in.", + "stays_no_requests": "No stay requests found.", + "stays_notes": "Notes", + "stays_notes_placeholder": "Any additional information...", + "stays_reject": "Reject", + "stays_reject_failed": "Failed to reject request.", + "stays_request_approved": "Request approved", + "stays_request_approved_message": "The stay request has been approved.", + "stays_request_rejected": "Request rejected", + "stays_request_rejected_message": "The stay request has been rejected.", + "stays_request_stay": "Request a Stay", + "stays_request_submitted": "Request submitted", + "stays_request_submitted_message": "Your stay request has been submitted for review.", + "stays_request_type": "Request Type", + "stays_requested_dates": "Requested Dates", + "stays_requests": "Requests", + "stays_select_type": "Select type", + "stays_something_went_wrong": "Something went wrong.", + "stays_start_date": "Start Date", + "stays_status": "Status", + "stays_stay_cancelled": "Stay cancelled", + "stays_stay_cancelled_message": "The stay has been cancelled.", + "stays_stay_created": "Stay created", + "stays_stay_created_message": "The stay has been created and is ready for check-in.", + "stays_stay_type": "Stay Type", + "stays_submit_request": "Submit Request", + "stays_title": "Stays", + "stays_type": "Type", + "stays_type_day_visit": "Day Visit", + "stays_type_facilitator": "Facilitator", + "stays_type_guest": "Guest", + "stays_type_staff": "Staff", + "stays_type_volunteer": "Volunteer", + "stays_user": "User", + "tasks_assign_role": "Assign Role", + "tasks_assign_to_role": "Assign Task to Role", + "tasks_assign_to_role_optional": "Assign to Role (optional)", + "tasks_block": "Block", + "tasks_block_title": "Mark Task Blocked", + "tasks_blocked": "Task marked as blocked", + "tasks_cancel": "Cancel", + "tasks_category": "Category", + "tasks_category_housekeeping": "Housekeeping", + "tasks_category_kitchen": "Kitchen", + "tasks_category_maintenance": "Maintenance", + "tasks_category_operations": "Operations", + "tasks_category_retreat": "Retreat", + "tasks_category_transport": "Transport", + "tasks_claim": "Claim", + "tasks_claimed": "Task claimed", + "tasks_complete": "Complete", + "tasks_complete_title": "Complete Task", + "tasks_completed": "Task completed", + "tasks_create_new_task": "Create New Task", + "tasks_create_task": "Create Task", + "tasks_created_message": "\"{title}\" has been created successfully.", + "tasks_created_title": "Task created", + "tasks_description": "View and manage village tasks across all categories.", + "tasks_description_placeholder": "Optional details about the task", + "tasks_error_assign_role": "Failed to assign role.", + "tasks_error_generate": "Failed to generate recurring tasks.", + "tasks_error_generic": "Something went wrong", + "tasks_error_title": "Error", + "tasks_field_description": "Description", + "tasks_field_title": "Title", + "tasks_generate_recurring": "Generate Recurring Tasks", + "tasks_generated_message": "Generated {count} recurring task(s).", + "tasks_generated_title": "Tasks generated", + "tasks_location": "Location", + "tasks_location_placeholder": "e.g. Main building, Kitchen", + "tasks_mark_blocked": "Mark Blocked", + "tasks_new_task": "New Task", + "tasks_no_status_tasks": "No {status} tasks", + "tasks_no_tasks": "No tasks", + "tasks_notes_optional": "Notes (optional)", + "tasks_notes_placeholder": "Any completion notes?", + "tasks_reason": "Reason", + "tasks_reason_placeholder": "Why is this task blocked?", + "tasks_role": "Role", + "tasks_role_admin": "Admin", + "tasks_role_assigned_message": "Task has been assigned to the selected role.", + "tasks_role_assigned_title": "Role assigned", + "tasks_role_boat_coordinator": "Boat Coordinator", + "tasks_role_community_member": "Community Member", + "tasks_role_coordinator": "Coordinator", + "tasks_role_facilitator": "Facilitator", + "tasks_role_kitchen_lead": "Kitchen Lead", + "tasks_role_staff": "Staff", + "tasks_role_volunteer": "Volunteer", + "tasks_scheduled_end": "Scheduled End", + "tasks_scheduled_start": "Scheduled Start", + "tasks_select_category": "Select category", + "tasks_select_role": "Select a role", + "tasks_start": "Start", + "tasks_started": "Task started", + "tasks_status_assigned": "Assigned", + "tasks_status_blocked": "Blocked", + "tasks_status_cancelled": "Cancelled", + "tasks_status_completed": "Completed", + "tasks_status_in_progress": "In Progress", + "tasks_status_open": "Open", + "tasks_status_planned": "Planned", + "tasks_templates": "Templates", + "tasks_templates_back": "Back to Tasks", + "tasks_templates_claimable": "Claimable", + "tasks_templates_col_category": "Category", + "tasks_templates_col_claimable": "Claimable", + "tasks_templates_col_duration": "Est. Duration", + "tasks_templates_col_recurrence": "Recurrence", + "tasks_templates_col_title": "Title", + "tasks_templates_description": "Reusable task definitions for recurring village operations.", + "tasks_templates_empty": "No templates yet. Create one above to get started.", + "tasks_templates_error": "Failed to load templates", + "tasks_templates_minutes": "{minutes} min", + "tasks_templates_no": "No", + "tasks_templates_not_claimable": "Not claimable", + "tasks_templates_one_time": "One-time", + "tasks_templates_recurring": "Recurring", + "tasks_templates_title": "Task Templates", + "tasks_templates_yes": "Yes", + "tasks_title": "Tasks", + "tasks_title_placeholder": "e.g. Clean guest rooms", + "tasks_validation_message": "Title and category are required.", + "tasks_validation_title": "Validation", + "templates_create": "Create Template", + "templates_created": "Template created", + "templates_field_category": "Category", + "templates_field_category_placeholder": "Select category", + "templates_field_claimable": "Claimable by volunteers", + "templates_field_default_location": "Default Location", + "templates_field_default_location_placeholder": "e.g. Main kitchen", + "templates_field_description": "Description", + "templates_field_description_placeholder": "What does this task involve?", + "templates_field_estimated_minutes": "Estimated Duration (minutes)", + "templates_field_estimated_minutes_placeholder": "e.g. 30", + "templates_field_recurrence": "Recurrence (cron)", + "templates_field_recurrence_placeholder": "e.g. 0 8 * * 1,3,5", + "templates_field_title": "Title", + "templates_field_title_placeholder": "e.g. Daily kitchen clean-up", + "templates_hide_form": "Hide Form", + "templates_new": "New Template", + "templates_recurrence_hint": "Leave blank for one-time tasks", + "templates_title_category_required": "Title and category are required.", + "user_select_label": "User", + "users_back": "Back to Users", + "users_display_name": "Display Name", + "users_edit_roles": "Edit Roles", + "users_first_name": "First Name", + "users_invite": "Invite User", + "users_last_name": "Last Name", + "users_name": "Name", + "users_no_roles": "No roles", + "users_not_found_body": "The user you are looking for does not exist or you do not have access.", + "users_not_found_title": "User Not Found", + "users_registered_count": "{count} users registered.", + "users_role_management": "Role Management", + "users_roles": "Roles", + "users_roles_updated": "Roles updated successfully.", + "users_save_roles": "Save Roles", + "users_send_invitation": "Send Invitation", + "users_status": "Status", + "users_title": "Users & Roles", + "profile_name": "Name", + "profile_name_placeholder": "Full name", + "profile_mobile_number": "Mobile number", + "profile_mobile_number_placeholder": "Mobile number", + "profile_whatsapp_number": "WhatsApp number", + "profile_whatsapp_number_placeholder": "WhatsApp number" +} diff --git a/apps/app/package.json b/apps/app/package.json new file mode 100644 index 0000000..b79eb51 --- /dev/null +++ b/apps/app/package.json @@ -0,0 +1,40 @@ +{ + "name": "@perauset/app", + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --port 6001", + "build": "tsc -b && vite build", + "preview": "vite preview --port 6001", + "tsc": "tsc --noEmit" + }, + "dependencies": { + "@mantine/core": "^9.2.1", + "@mantine/hooks": "^9.2.1", + "@mantine/notifications": "^9.2.1", + "@perauset/components": "workspace:*", + "@perauset/functions": "workspace:*", + "@perauset/functions-sdk": "workspace:*", + "@perauset/mantine-theme": "workspace:*", + "@pikku/fetch": "^0.12.6", + "@pikku/mantine": "^0.12.6", + "@pikku/react": "^0.12.5", + "@tanstack/react-query": "^5.90.10", + "@tanstack/react-router": "^1.100.0", + "@tanstack/react-start": "^1.100.0", + "better-auth": "^1.6.19", + "lucide-react": "^0.554.0", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@inlang/paraglide-js": "^2.20.0", + "@tanstack/router-generator": "^1.100.0", + "@tanstack/router-plugin": "^1.100.0", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4", + "typescript": "^5.9" + } +} diff --git a/apps/app/project.inlang/settings.json b/apps/app/project.inlang/settings.json new file mode 100644 index 0000000..a4a5812 --- /dev/null +++ b/apps/app/project.inlang/settings.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "de"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/apps/app/src/components/ai-chat.tsx b/apps/app/src/components/ai-chat.tsx new file mode 100644 index 0000000..cf7adb4 --- /dev/null +++ b/apps/app/src/components/ai-chat.tsx @@ -0,0 +1,305 @@ +import { useState, useRef, useEffect } from 'react' +import { + Drawer, + Stack, + Group, + Text, + TextInput, + ActionIcon, + ScrollArea, + Paper, + Avatar, + Loader, + Box, +} from '@pikku/mantine/core' +import { asI18n } from '@pikku/react' +import { m } from '@/i18n/messages' +import { Send, Bot } from 'lucide-react' +import { useSessionUser } from '@/lib/auth-context' +import { usePikkuRPC } from '@pikku/react' +import type { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen' + +interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +interface AIChatDrawerProps { + opened: boolean + onClose: () => void +} + +export function AIChatDrawer({ opened, onClose }: AIChatDrawerProps) { + const rpc = usePikkuRPC() + const userName = useSessionUser()?.name ?? '' + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const [threadId, setThreadId] = useState(null) + const viewportRef = useRef(null) + const inputRef = useRef(null) + + const userInitial = userName?.[0]?.toUpperCase() ?? 'U' + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (viewportRef.current) { + viewportRef.current.scrollTo({ + top: viewportRef.current.scrollHeight, + behavior: 'smooth', + }) + } + }, [messages, loading]) + + // Auto-focus input when drawer opens + useEffect(() => { + if (opened) { + setTimeout(() => { + inputRef.current?.focus() + }, 200) + } + }, [opened]) + + async function sendMessage(message: string) { + if (!message.trim() || loading) return + + const userMessage = message.trim() + setInput('') + setLoading(true) + setMessages((prev) => [...prev, { role: 'user', content: userMessage }]) + + try { + const currentThreadId = threadId || crypto.randomUUID() + if (!threadId) setThreadId(currentThreadId) + + // TODO: confirm agent RPC — runs the `perauset-router` agent to completion. + const res = await rpc.agent.run('perauset-router' as any, { + message: userMessage, + threadId: currentThreadId, + resourceId: currentThreadId, + }) + + const result = res?.result as + | string + | { text?: string; message?: string } + | undefined + const assistantMessage = + typeof result === 'string' + ? result + : result?.text || result?.message || JSON.stringify(result) + + setMessages((prev) => [ + ...prev, + { role: 'assistant', content: assistantMessage }, + ]) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Request failed' + setMessages((prev) => [ + ...prev, + { role: 'assistant', content: `Error: ${msg}` }, + ]) + } finally { + setLoading(false) + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage(input) + } + } + + return ( + + + + + + {m.ai_chat_title()} + + + } + styles={{ + content: { + display: 'flex', + flexDirection: 'column', + }, + body: { + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + padding: 0, + }, + }} + > + {/* Messages area */} + + {messages.length === 0 && ( + + + + + + {m.ai_chat_greeting()} + + + {m.ai_chat_subtitle()} + + + )} + + + {messages.map((msg, i) => ( + + {msg.role === 'assistant' && ( + + + + )} + + + {asI18n(msg.content)} + + + {msg.role === 'user' && ( + + {userInitial} + + )} + + ))} + + {/* Typing indicator */} + {loading && ( + + + + + + + + + + + )} + + + + {/* Input bar */} + + + setInput(e.currentTarget.value)} + onKeyDown={handleKeyDown} + placeholder={m.ai_chat_input_placeholder()} + disabled={loading} + radius="xl" + size="md" + style={{ flex: 1 }} + styles={{ + input: { + border: '1px solid rgba(61, 43, 31, 0.15)', + '&:focus': { + borderColor: '#2A7B88', + }, + }, + }} + /> + sendMessage(input)} + disabled={!input.trim() || loading} + aria-label={asI18n('Send message')} + > + + + + + + ) +} diff --git a/apps/app/src/components/create-template-form.tsx b/apps/app/src/components/create-template-form.tsx new file mode 100644 index 0000000..0e0497b --- /dev/null +++ b/apps/app/src/components/create-template-form.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react' +import { + Card, + Stack, + TextInput, + Textarea, + Select, + Group, + Button, + Switch, + NumberInput, + Collapse, + Title, +} from '@pikku/mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' +import { m } from '@/i18n/messages' +import { useQueryClient } from '@tanstack/react-query' +import { Plus } from 'lucide-react' +import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen' + +type Category = + | 'housekeeping' + | 'kitchen' + | 'maintenance' + | 'operations' + | 'retreat' + | 'transport' + +const CATEGORIES: { value: Category; label: string }[] = [ + { value: 'housekeeping', label: 'Housekeeping' }, + { value: 'kitchen', label: 'Kitchen' }, + { value: 'maintenance', label: 'Maintenance' }, + { value: 'operations', label: 'Operations' }, + { value: 'retreat', label: 'Retreat' }, + { value: 'transport', label: 'Transport' }, +] + +export function CreateTemplateForm() { + const queryClient = useQueryClient() + const [opened, { toggle }] = useDisclosure(false) + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [category, setCategory] = useState(null) + const [isClaimable, setIsClaimable] = useState(false) + const [defaultLocation, setDefaultLocation] = useState('') + const [estimatedMinutes, setEstimatedMinutes] = useState('') + const [recurrenceCron, setRecurrenceCron] = useState('') + + const resetForm = () => { + setTitle('') + setDescription('') + setCategory(null) + setIsClaimable(false) + setDefaultLocation('') + setEstimatedMinutes('') + setRecurrenceCron('') + } + + const mutation = usePikkuMutation('createTaskTemplate', { + onSuccess: () => { + notifications.show({ + title: m.templates_created(), + message: `"${title}" template has been created.`, + color: 'teal', + }) + resetForm() + toggle() + queryClient.invalidateQueries({ + predicate: (q) => + typeof q.queryKey[0] === 'string' && + (q.queryKey[0] as string).toLowerCase().includes('template'), + }) + }, + onError: (err: Error) => { + notifications.show({ + title: m.common_error(), + message: err.message ?? 'Something went wrong', + color: 'red', + }) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!title.trim() || !category) { + notifications.show({ + title: m.common_validation(), + message: m.templates_title_category_required(), + color: 'orange', + }) + return + } + + const body = { + title: title.trim(), + category, + ...(description.trim() ? { description: description.trim() } : {}), + ...(isClaimable ? { isClaimable: true } : {}), + ...(defaultLocation.trim() ? { defaultLocation: defaultLocation.trim() } : {}), + ...(typeof estimatedMinutes === 'number' && estimatedMinutes > 0 + ? { estimatedMinutes } + : {}), + ...(recurrenceCron.trim() ? { recurrenceCron: recurrenceCron.trim() } : {}), + } + + mutation.mutate(body) + } + + return ( + <> + + + + + + {m.templates_new()} + +
+ + setTitle(e.currentTarget.value)} + required + /> + +