diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e3f493..13b7ddc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,7 @@ npm run typecheck # 3. Lint — zero warnings (warnings are errors) npm run lint -w @acroyoga/web -# 4. Run all tests — tokens (20) → shared-ui (85) → web (580+) +# 4. Run all tests — tokens (20) → shared-ui (85) → web (630+) npm run test # 5. Production build — must succeed diff --git a/README.md b/README.md index 7c66076..7e6dd87 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ This is an **npm workspaces monorepo** with shared packages: │ ├── specs/ # Spec-Kit feature specifications │ ├── constitution.md # Architectural principles (v1.5.0) -│ └── 001–012/ # Feature specs with plans, tasks, contracts +│ └── 001–016/ # Feature specs with plans, tasks, contracts │ └── .agent.md # UI Expert agent configuration ``` @@ -93,8 +93,28 @@ Each feature is developed from a full spec (user scenarios, data model, API cont | 011b | [Entra External ID](specs/011-entra-external-id/) | P1 | Implemented | | 012 | [Managed Identity Deploy](specs/012-managed-identity-deploy/) | P2 | Implemented | | 013 | [Platform Improvements](specs/013-platform-improvements/) | P2 | Complete | +| 014 | [Internationalisation](specs/014-internationalisation/) | P1 | Planned | +| 015 | [Background Jobs & Notifications](specs/015-background-jobs-notifications/) | P1 | Planned | +| 016 | [Mobile App (Expo/React Native)](specs/016-mobile-app/) | P1 | Planned | -> Specs 006 and 007 are internal infrastructure (security hardening, dev tooling, UI pages). Spec 008 mobile phases are deferred. Specs 011–012 cover Azure production deployment with Managed Identity and Entra External ID social login. Spec 013 added CONTRIBUTING.md, API reference docs, database/testing docs, Playwright E2E tests, and triaged all remaining tasks across specs 001–010. +> Specs 006 and 007 are internal infrastructure (security hardening, dev tooling, UI pages). Specs 011–012 cover Azure production deployment with Managed Identity and Entra External ID social login. Spec 013 added CONTRIBUTING.md, API reference docs, database/testing docs, Playwright E2E tests, and triaged all remaining tasks across specs 001–010. Specs 014–016 are the next wave of features — i18n (Constitution VIII), background jobs & notifications (Constitution X), and native mobile apps completing the cross-platform vision from Spec 008. + +## Roadmap + +The platform is **feature-complete for web**. All P0 and P1 features are implemented across 13 specs (001–013). The next wave of work is captured in three new specs: + +| Priority | Spec | Scope | Tasks | +|----------|------|-------|-------| +| **Next** | [014 — Internationalisation](specs/014-internationalisation/) | `next-intl` integration, string extraction (~200+ strings), `Intl.DateTimeFormat` migration, RTL support, locale switcher, CI enforcement | 44 tasks, 6 phases | +| **Next** | [015 — Background Jobs & Notifications](specs/015-background-jobs-notifications/) | `pg-boss` job queue, in-app notifications, email delivery (Azure Communication Services), notification preferences, scheduled jobs (review reminders, cert-expiry) | 45 tasks, 7 phases | +| **Future** | [016 — Mobile App](specs/016-mobile-app/) | Expo/React Native, 5-tab navigation, JWT auth, TanStack Query + MMKV offline, push notifications. Completes Spec 008's deferred mobile phases | 63 tasks, 10 phases | + +Additional deferred work (lower priority, not yet specced): +- **UI Component Extraction** — 21 presentational component wrappers (Spec 001 deferred tasks) +- **Performance Optimization** — Image optimization, lazy loading, skeleton loaders +- **WCAG Manual Audit** — Keyboard navigation and screen reader testing beyond axe-core automation +- **SEO & Social Sharing** — OG metadata generation, event sharing cards +- **Geolocation & Heatmap** — "Near Me" button and event density heatmap (Spec 010 deferred) ## Documentation @@ -154,7 +174,7 @@ npm run lint # ESLint (includes jsx-a11y) ```bash npm run test -w @acroyoga/tokens # Run token pipeline tests (20 tests) npm run test -w @acroyoga/shared-ui # Run shared-ui component tests (85 tests) -npm run test -w @acroyoga/web # Run web integration tests (339 tests) +npm run test -w @acroyoga/web # Run web integration tests (630+ tests) npm run tokens:build # Rebuild design tokens npm run tokens:watch # Watch token source & rebuild on change ``` diff --git a/apps/web/tests/integration/account/export-download.test.ts b/apps/web/tests/integration/account/export-download.test.ts new file mode 100644 index 0000000..c45f399 --- /dev/null +++ b/apps/web/tests/integration/account/export-download.test.ts @@ -0,0 +1,160 @@ +/** + * Integration tests for GET /api/account/exports/[id]/download — GDPR export download + * + * Tests: + * - 401 for unauthenticated requests + * - 404 for non-existent export + * - 400 for incomplete export + * - 200 with JSON attachment for completed export + * - Ownership check: user cannot download another user's export + * + * Constitution II (Test-First), III (Privacy & Data Protection) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { PGlite } from "@electric-sql/pglite"; +import { setTestDb, clearTestDb } from "@/lib/db/client"; +import fs from "fs"; +import path from "path"; + +// Mock getServerSession +vi.mock("@/lib/auth/session", () => ({ + getServerSession: vi.fn(), +})); + +// Mock GDPR export to avoid cross-module complications +vi.mock("@/lib/gdpr/export", () => ({ + exportUserData: vi.fn().mockResolvedValue({}), +})); + +import { getServerSession } from "@/lib/auth/session"; +const mockGetServerSession = vi.mocked(getServerSession); + +let pg: PGlite; + +async function applyMigrations(d: PGlite) { + const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations"); + const files = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); + await d.exec(sql); + } +} + +async function createUser(d: PGlite, email: string): Promise { + const result = await d.query<{ id: string }>( + "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id", + [email, email.split("@")[0]], + ); + return result.rows[0].id; +} + +describe("GET /api/account/exports/[id]/download", () => { + let userId: string; + let otherUserId: string; + + beforeEach(async () => { + pg = new PGlite(); + await applyMigrations(pg); + setTestDb(pg); + + userId = await createUser(pg, "user@test.com"); + otherUserId = await createUser(pg, "other@test.com"); + }); + + afterEach(async () => { + clearTestDb(); + vi.resetAllMocks(); + await pg.close(); + }); + + async function callDownload( + exportId: string, + sessionOverride?: { userId: string } | null, + ) { + if (sessionOverride === null) { + mockGetServerSession.mockResolvedValue(null); + } else if (sessionOverride) { + mockGetServerSession.mockResolvedValue(sessionOverride); + } + + const { GET } = await import( + "@/app/api/account/exports/[id]/download/route" + ); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/account/exports/${exportId}/download`, + ); + return GET(request, { params: Promise.resolve({ id: exportId }) }); + } + + it("returns 401 for unauthenticated request", async () => { + const response = await callDownload("some-id", null); + expect(response.status).toBe(401); + }); + + it("returns 404 for non-existent export", async () => { + const response = await callDownload( + "00000000-0000-0000-0000-000000000000", + { userId }, + ); + expect(response.status).toBe(404); + + const body = await response.json(); + expect(body.error).toContain("not found"); + }); + + it("returns 404 when accessing another user's export (ownership check)", async () => { + // Create export for userId + const exportResult = await pg.query<{ id: string }>( + "INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id", + [userId], + ); + const exportId = exportResult.rows[0].id; + + // Try to access as otherUser + const response = await callDownload(exportId, { userId: otherUserId }); + expect(response.status).toBe(404); + }); + + it("returns 400 when export is not yet completed", async () => { + const exportResult = await pg.query<{ id: string }>( + "INSERT INTO data_exports (user_id, status) VALUES ($1, 'pending') RETURNING id", + [userId], + ); + const exportId = exportResult.rows[0].id; + + const response = await callDownload(exportId, { userId }); + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.error).toContain("not yet completed"); + }); + + it("returns 200 with JSON attachment for completed export", async () => { + const exportResult = await pg.query<{ id: string }>( + "INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id", + [userId], + ); + const exportId = exportResult.rows[0].id; + + const response = await callDownload(exportId, { userId }); + expect(response.status).toBe(200); + + expect(response.headers.get("Content-Type")).toBe("application/json"); + expect(response.headers.get("Content-Disposition")).toContain( + `data-export-${exportId}.json`, + ); + + const text = await response.text(); + const data = JSON.parse(text); + // Should be a valid export schema with expected keys + expect(data).toHaveProperty("rsvps"); + expect(data).toHaveProperty("follows"); + expect(data).toHaveProperty("blocks"); + expect(data).toHaveProperty("mutes"); + }); +}); diff --git a/apps/web/tests/integration/payments/callback.test.ts b/apps/web/tests/integration/payments/callback.test.ts new file mode 100644 index 0000000..0285e6a --- /dev/null +++ b/apps/web/tests/integration/payments/callback.test.ts @@ -0,0 +1,118 @@ +/** + * Integration tests for GET /api/payments/callback — Stripe OAuth callback + * + * Tests redirect behavior for: + * - Error from Stripe → redirect with error description + * - Missing code/state → redirect with missing_params error + * - Successful callback → redirect with success status + * - handleCallback failure → redirect with connection_failed error + * + * Constitution II (Test-First), XII (Financial Integrity) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock handleCallback to avoid real Stripe API calls +vi.mock("@/lib/payments/stripe-connect", () => ({ + handleCallback: vi.fn(), +})); + +import { handleCallback } from "@/lib/payments/stripe-connect"; +const mockHandleCallback = vi.mocked(handleCallback); + +describe("GET /api/payments/callback", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + async function callCallback(searchParams: Record) { + const url = new URL("http://localhost/api/payments/callback"); + for (const [key, value] of Object.entries(searchParams)) { + url.searchParams.set(key, value); + } + const request = new NextRequest(url); + const { GET } = await import("@/app/api/payments/callback/route"); + return GET(request); + } + + it("redirects with error description when Stripe returns an error", async () => { + const response = await callCallback({ + error: "access_denied", + error_description: "User denied access", + }); + + expect(response.status).toBe(307); + const location = response.headers.get("Location")!; + expect(location).toContain("/settings/creator"); + expect(location).toContain("error=User%20denied%20access"); + }); + + it("uses default error description when none provided", async () => { + const response = await callCallback({ + error: "access_denied", + }); + + expect(response.status).toBe(307); + const location = response.headers.get("Location")!; + expect(location).toContain("error=Unknown%20error"); + }); + + it("redirects with missing_params when code is absent", async () => { + const response = await callCallback({ + state: "user-123", + }); + + expect(response.status).toBe(307); + const location = response.headers.get("Location")!; + expect(location).toContain("error=missing_params"); + }); + + it("redirects with missing_params when state is absent", async () => { + const response = await callCallback({ + code: "auth_code_123", + }); + + expect(response.status).toBe(307); + const location = response.headers.get("Location")!; + expect(location).toContain("error=missing_params"); + }); + + it("redirects with success when handleCallback succeeds", async () => { + mockHandleCallback.mockResolvedValue({ + id: "pay_1", + userId: "user-123", + stripeAccountId: "acct_test", + onboardingComplete: false, + connectedAt: new Date().toISOString(), + disconnectedAt: null, + }); + + const response = await callCallback({ + code: "auth_code_123", + state: "user-123", + }); + + expect(response.status).toBe(307); + const location = response.headers.get("Location")!; + expect(location).toContain("status=success"); + expect(mockHandleCallback).toHaveBeenCalledWith("auth_code_123", "user-123"); + }); + + it("redirects with connection_failed when handleCallback throws", async () => { + mockHandleCallback.mockRejectedValue(new Error("Stripe API error")); + + const response = await callCallback({ + code: "bad_code", + state: "user-123", + }); + + expect(response.status).toBe(307); + const location = response.headers.get("Location")!; + expect(location).toContain("error=connection_failed"); + }); +}); diff --git a/apps/web/tests/integration/payments/connect-route.test.ts b/apps/web/tests/integration/payments/connect-route.test.ts new file mode 100644 index 0000000..8a7ad26 --- /dev/null +++ b/apps/web/tests/integration/payments/connect-route.test.ts @@ -0,0 +1,152 @@ +/** + * HTTP-level integration tests for POST /api/payments/connect + * + * Tests: + * - 401 for unauthenticated requests + * - 403 for users without event_creator role + * - 409 when already connected to Stripe + * - 200 with redirect URL for valid creator + * + * Constitution II (Test-First), IX (Scoped Permissions), XII (Financial Integrity) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { PGlite } from "@electric-sql/pglite"; +import { setTestDb, clearTestDb } from "@/lib/db/client"; +import { clearCache } from "@/lib/permissions/cache"; +import fs from "fs"; +import path from "path"; + +// Mock getServerSession so we can control auth state +vi.mock("@/lib/auth/session", () => ({ + getServerSession: vi.fn(), +})); + +import { getServerSession } from "@/lib/auth/session"; +const mockGetServerSession = vi.mocked(getServerSession); + +let pg: PGlite; + +async function applyMigrations(d: PGlite) { + const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations"); + const files = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); + await d.exec(sql); + } +} + +async function createUser(d: PGlite, email: string): Promise { + const result = await d.query<{ id: string }>( + "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id", + [email, email.split("@")[0]], + ); + return result.rows[0].id; +} + +describe("POST /api/payments/connect (HTTP)", () => { + let creatorId: string; + let memberId: string; + let adminId: string; + + beforeEach(async () => { + pg = new PGlite(); + await applyMigrations(pg); + setTestDb(pg); + clearCache(); + + adminId = await createUser(pg, "admin@test.com"); + creatorId = await createUser(pg, "creator@test.com"); + memberId = await createUser(pg, "member@test.com"); + + // Seed geography + await pg.query( + `INSERT INTO geography (city, country, continent, display_name_city, display_name_country, display_name_continent) + VALUES ('bristol', 'uk', 'europe', 'Bristol', 'United Kingdom', 'Europe') + ON CONFLICT (city) DO NOTHING`, + ); + + // Admin grant + await pg.query( + "INSERT INTO permission_grants (user_id, role, scope_type, scope_value, granted_by) VALUES ($1, 'global_admin', 'global', NULL, $1)", + [adminId], + ); + + // Creator grant — global scope (connect route checks global) + await pg.query( + "INSERT INTO permission_grants (user_id, role, scope_type, scope_value, granted_by) VALUES ($1, 'event_creator', 'global', NULL, $2)", + [creatorId, adminId], + ); + + // Set required env vars + process.env.STRIPE_CLIENT_ID = "ca_test_fake"; + process.env.NEXTAUTH_URL = "http://localhost:3000"; + }); + + afterEach(async () => { + clearTestDb(); + clearCache(); + vi.resetAllMocks(); + await pg.close(); + delete process.env.STRIPE_CLIENT_ID; + delete process.env.NEXTAUTH_URL; + }); + + async function callConnect(sessionOverride?: { userId: string } | null) { + if (sessionOverride === null) { + mockGetServerSession.mockResolvedValue(null); + } else if (sessionOverride) { + mockGetServerSession.mockResolvedValue(sessionOverride); + } + + const { POST } = await import("@/app/api/payments/connect/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest("http://localhost/api/payments/connect", { + method: "POST", + }); + return POST(request); + } + + it("returns 401 for unauthenticated request", async () => { + const response = await callConnect(null); + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.error).toBeDefined(); + }); + + it("returns 403 for member without event_creator role", async () => { + const response = await callConnect({ userId: memberId }); + expect(response.status).toBe(403); + + const body = await response.json(); + expect(body.error).toContain("Event Creator"); + }); + + it("returns 409 when already connected to Stripe", async () => { + await pg.query( + "INSERT INTO creator_payment_accounts (user_id, stripe_account_id) VALUES ($1, $2)", + [creatorId, "acct_existing"], + ); + + const response = await callConnect({ userId: creatorId }); + expect(response.status).toBe(409); + + const body = await response.json(); + expect(body.error).toContain("Already connected"); + }); + + it("returns 200 with Stripe Connect redirect URL for valid creator", async () => { + const response = await callConnect({ userId: creatorId }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.redirectUrl).toBeDefined(); + expect(body.redirectUrl).toContain("connect.stripe.com/oauth/authorize"); + expect(body.redirectUrl).toContain("ca_test_fake"); + expect(body.redirectUrl).toContain(creatorId); + }); +}); diff --git a/apps/web/tests/integration/payments/status-route.test.ts b/apps/web/tests/integration/payments/status-route.test.ts new file mode 100644 index 0000000..1f7f60f --- /dev/null +++ b/apps/web/tests/integration/payments/status-route.test.ts @@ -0,0 +1,121 @@ +/** + * HTTP-level integration tests for GET /api/payments/status + * + * Tests: + * - 401 for unauthenticated requests + * - 200 with not-connected status for new user + * - 200 with connected status after Stripe account creation + * + * Constitution II (Test-First), XII (Financial Integrity) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { PGlite } from "@electric-sql/pglite"; +import { setTestDb, clearTestDb } from "@/lib/db/client"; +import fs from "fs"; +import path from "path"; + +// Mock getServerSession +vi.mock("@/lib/auth/session", () => ({ + getServerSession: vi.fn(), +})); + +import { getServerSession } from "@/lib/auth/session"; +const mockGetServerSession = vi.mocked(getServerSession); + +let pg: PGlite; + +async function applyMigrations(d: PGlite) { + const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations"); + const files = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); + await d.exec(sql); + } +} + +async function createUser(d: PGlite, email: string): Promise { + const result = await d.query<{ id: string }>( + "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id", + [email, email.split("@")[0]], + ); + return result.rows[0].id; +} + +describe("GET /api/payments/status (HTTP)", () => { + let userId: string; + + beforeEach(async () => { + pg = new PGlite(); + await applyMigrations(pg); + setTestDb(pg); + + userId = await createUser(pg, "user@test.com"); + }); + + afterEach(async () => { + clearTestDb(); + vi.resetAllMocks(); + await pg.close(); + }); + + async function callStatus(sessionOverride?: { userId: string } | null) { + if (sessionOverride === null) { + mockGetServerSession.mockResolvedValue(null); + } else if (sessionOverride) { + mockGetServerSession.mockResolvedValue(sessionOverride); + } + + const { GET } = await import("@/app/api/payments/status/route"); + return GET(); + } + + it("returns 401 for unauthenticated request", async () => { + const response = await callStatus(null); + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.error).toBeDefined(); + }); + + it("returns not-connected status for new user", async () => { + const response = await callStatus({ userId }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.connected).toBe(false); + expect(body.onboardingComplete).toBe(false); + expect(body.account).toBeNull(); + }); + + it("returns connected status after Stripe account creation", async () => { + await pg.query( + "INSERT INTO creator_payment_accounts (user_id, stripe_account_id) VALUES ($1, $2)", + [userId, "acct_status_test"], + ); + + const response = await callStatus({ userId }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.connected).toBe(true); + expect(body.account.stripeAccountId).toBe("acct_status_test"); + }); + + it("reflects onboarding completion status", async () => { + await pg.query( + "INSERT INTO creator_payment_accounts (user_id, stripe_account_id, onboarding_complete) VALUES ($1, $2, true)", + [userId, "acct_onboard_test"], + ); + + const response = await callStatus({ userId }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.connected).toBe(true); + expect(body.onboardingComplete).toBe(true); + }); +}); diff --git a/apps/web/tests/integration/payments/webhook.test.ts b/apps/web/tests/integration/payments/webhook.test.ts new file mode 100644 index 0000000..7d607e1 --- /dev/null +++ b/apps/web/tests/integration/payments/webhook.test.ts @@ -0,0 +1,184 @@ +/** + * Integration tests for POST /api/payments/webhook — Stripe webhook handler + * + * Tests the HTTP-level webhook endpoint behavior including: + * - Missing signature → 400 + * - Invalid signature → 400 + * - Valid account.updated event → updates onboarding status + * - Non-account.updated events → acknowledged but no side effects + * + * Constitution II (Test-First), XII (Financial Integrity) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { PGlite } from "@electric-sql/pglite"; +import { setTestDb, clearTestDb } from "@/lib/db/client"; +import fs from "fs"; +import path from "path"; + +// Mock Stripe to avoid real API calls +const mockConstructEvent = vi.fn(); + +vi.mock("stripe", () => { + return { + default: class MockStripe { + webhooks = { + constructEvent: mockConstructEvent, + }; + }, + }; +}); + +let pg: PGlite; + +async function applyMigrations(d: PGlite) { + const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations"); + const files = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); + await d.exec(sql); + } +} + +async function createUser(d: PGlite, email: string): Promise { + const result = await d.query<{ id: string }>( + "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id", + [email, email.split("@")[0]], + ); + return result.rows[0].id; +} + +describe("POST /api/payments/webhook", () => { + let userId: string; + + beforeEach(async () => { + pg = new PGlite(); + await applyMigrations(pg); + setTestDb(pg); + + userId = await createUser(pg, "creator@test.com"); + + // Set required env vars + process.env.STRIPE_SECRET_KEY = "sk_test_fake"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake"; + }); + + afterEach(async () => { + clearTestDb(); + vi.resetAllMocks(); + await pg.close(); + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + }); + + async function callWebhook(body: string, signature: string | null) { + const { POST } = await import("@/app/api/payments/webhook/route"); + const headers = new Headers(); + if (signature) { + headers.set("stripe-signature", signature); + } + const request = new Request("http://localhost/api/payments/webhook", { + method: "POST", + body, + headers, + }); + // NextRequest constructor accepts a Request + const { NextRequest } = await import("next/server"); + return POST(new NextRequest(request)); + } + + it("returns 400 when stripe-signature header is missing", async () => { + const response = await callWebhook("{}", null); + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.error).toBe("Missing signature"); + }); + + it("returns 400 when signature is invalid", async () => { + mockConstructEvent.mockImplementation(() => { + throw new Error("Invalid signature"); + }); + + const response = await callWebhook("{}", "invalid_sig"); + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.error).toBe("Invalid signature"); + }); + + it("processes account.updated event and updates onboarding status", async () => { + // Create a payment account + await pg.query( + "INSERT INTO creator_payment_accounts (user_id, stripe_account_id) VALUES ($1, $2)", + [userId, "acct_webhook_test"], + ); + + mockConstructEvent.mockReturnValue({ + type: "account.updated", + data: { + object: { + id: "acct_webhook_test", + charges_enabled: true, + payouts_enabled: true, + }, + }, + }); + + const response = await callWebhook('{"type":"account.updated"}', "valid_sig"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.received).toBe(true); + + // Verify onboarding was updated + const account = await pg.query<{ onboarding_complete: boolean }>( + "SELECT onboarding_complete FROM creator_payment_accounts WHERE stripe_account_id = $1", + ["acct_webhook_test"], + ); + expect(account.rows[0].onboarding_complete).toBe(true); + }); + + it("sets onboarding incomplete when charges or payouts not enabled", async () => { + await pg.query( + "INSERT INTO creator_payment_accounts (user_id, stripe_account_id, onboarding_complete) VALUES ($1, $2, true)", + [userId, "acct_incomplete"], + ); + + mockConstructEvent.mockReturnValue({ + type: "account.updated", + data: { + object: { + id: "acct_incomplete", + charges_enabled: true, + payouts_enabled: false, + }, + }, + }); + + const response = await callWebhook("{}", "valid_sig"); + expect(response.status).toBe(200); + + const account = await pg.query<{ onboarding_complete: boolean }>( + "SELECT onboarding_complete FROM creator_payment_accounts WHERE stripe_account_id = $1", + ["acct_incomplete"], + ); + expect(account.rows[0].onboarding_complete).toBe(false); + }); + + it("acknowledges non-account.updated events without side effects", async () => { + mockConstructEvent.mockReturnValue({ + type: "payment_intent.succeeded", + data: { object: { id: "pi_test" } }, + }); + + const response = await callWebhook("{}", "valid_sig"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.received).toBe(true); + }); +}); diff --git a/apps/web/tests/integration/teachers/photos.test.ts b/apps/web/tests/integration/teachers/photos.test.ts new file mode 100644 index 0000000..dab78e0 --- /dev/null +++ b/apps/web/tests/integration/teachers/photos.test.ts @@ -0,0 +1,302 @@ +/** + * Integration tests for /api/teachers/[id]/photos — Teacher photo CRUD + * + * Tests: + * - GET returns photos for a teacher profile + * - POST adds a photo (auth required) + * - POST validates Zod schema (invalid URL) + * - POST enforces max 10 photo limit + * - DELETE removes a photo (auth required) + * - DELETE returns 404 for non-existent photo + * - 401 for unauthenticated POST/DELETE + * + * Constitution II (Test-First), IV (Server-Side Authority) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { PGlite } from "@electric-sql/pglite"; +import { setTestDb, clearTestDb } from "@/lib/db/client"; +import fs from "fs"; +import path from "path"; + +// Mock getServerSession +vi.mock("@/lib/auth/session", () => ({ + getServerSession: vi.fn(), +})); + +import { getServerSession } from "@/lib/auth/session"; +const mockGetServerSession = vi.mocked(getServerSession); + +let pg: PGlite; +let userId: string; +let profileId: string; + +async function applyMigrations(d: PGlite) { + const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations"); + const files = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); + await d.exec(sql); + } +} + +describe("Teacher Photos API", () => { + beforeEach(async () => { + pg = new PGlite(); + await applyMigrations(pg); + setTestDb(pg); + + const u = await pg.query<{ id: string }>( + "INSERT INTO users (email, name) VALUES ('teacher@test.com', 'Test Teacher') RETURNING id", + ); + userId = u.rows[0].id; + + const p = await pg.query<{ id: string }>( + `INSERT INTO teacher_profiles (user_id, bio, specialties, badge_status) + VALUES ($1, 'A great teacher', $2, 'verified') + RETURNING id`, + [userId, ["washing_machines"]], + ); + profileId = p.rows[0].id; + + mockGetServerSession.mockResolvedValue({ userId }); + }); + + afterEach(async () => { + clearTestDb(); + vi.resetAllMocks(); + await pg.close(); + }); + + // --- GET /api/teachers/[id]/photos --- + describe("GET /api/teachers/[id]/photos", () => { + it("returns empty array when no photos exist", async () => { + const { GET } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + ); + const response = await GET(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual([]); + }); + + it("returns photos in sort order", async () => { + await pg.query( + "INSERT INTO teacher_photos (teacher_profile_id, url, sort_order) VALUES ($1, $2, 1)", + [profileId, "https://example.com/b.jpg"], + ); + await pg.query( + "INSERT INTO teacher_photos (teacher_profile_id, url, sort_order) VALUES ($1, $2, 0)", + [profileId, "https://example.com/a.jpg"], + ); + + const { GET } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + ); + const response = await GET(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toHaveLength(2); + expect(body[0].url).toBe("https://example.com/a.jpg"); + expect(body[1].url).toBe("https://example.com/b.jpg"); + }); + }); + + // --- POST /api/teachers/[id]/photos --- + describe("POST /api/teachers/[id]/photos", () => { + it("returns 401 for unauthenticated request", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { POST } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + { + method: "POST", + body: JSON.stringify({ url: "https://example.com/photo.jpg" }), + headers: { "Content-Type": "application/json" }, + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(401); + }); + + it("creates a photo with auto-incremented sort order", async () => { + const { POST } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + { + method: "POST", + body: JSON.stringify({ url: "https://example.com/new.jpg" }), + headers: { "Content-Type": "application/json" }, + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.url).toBe("https://example.com/new.jpg"); + expect(body.sort_order).toBe(0); + expect(body.id).toBeDefined(); + }); + + it("creates a photo with explicit display_order", async () => { + const { POST } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + { + method: "POST", + body: JSON.stringify({ + url: "https://example.com/ordered.jpg", + display_order: 5, + }), + headers: { "Content-Type": "application/json" }, + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.sort_order).toBe(5); + }); + + it("returns 400 for invalid URL", async () => { + const { POST } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + { + method: "POST", + body: JSON.stringify({ url: "not-a-url" }), + headers: { "Content-Type": "application/json" }, + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(400); + }); + + it("returns 400 when max 10 photos reached", async () => { + // Insert 10 photos + for (let i = 0; i < 10; i++) { + await pg.query( + "INSERT INTO teacher_photos (teacher_profile_id, url, sort_order) VALUES ($1, $2, $3)", + [profileId, `https://example.com/photo${i}.jpg`, i], + ); + } + + const { POST } = await import("@/app/api/teachers/[id]/photos/route"); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos`, + { + method: "POST", + body: JSON.stringify({ url: "https://example.com/11th.jpg" }), + headers: { "Content-Type": "application/json" }, + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: profileId }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Maximum 10"); + }); + }); + + // --- DELETE /api/teachers/[id]/photos/[photoId] --- + describe("DELETE /api/teachers/[id]/photos/[photoId]", () => { + it("returns 401 for unauthenticated request", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { DELETE } = await import( + "@/app/api/teachers/[id]/photos/[photoId]/route" + ); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos/some-id`, + { method: "DELETE" }, + ); + const response = await DELETE(request, { + params: Promise.resolve({ id: profileId, photoId: "some-id" }), + }); + + expect(response.status).toBe(401); + }); + + it("returns 404 for non-existent photo", async () => { + const { DELETE } = await import( + "@/app/api/teachers/[id]/photos/[photoId]/route" + ); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos/00000000-0000-0000-0000-000000000000`, + { method: "DELETE" }, + ); + const response = await DELETE(request, { + params: Promise.resolve({ + id: profileId, + photoId: "00000000-0000-0000-0000-000000000000", + }), + }); + + expect(response.status).toBe(404); + }); + + it("deletes an existing photo", async () => { + const photo = await pg.query<{ id: string }>( + "INSERT INTO teacher_photos (teacher_profile_id, url, sort_order) VALUES ($1, $2, 0) RETURNING id", + [profileId, "https://example.com/delete-me.jpg"], + ); + const photoId = photo.rows[0].id; + + const { DELETE } = await import( + "@/app/api/teachers/[id]/photos/[photoId]/route" + ); + const { NextRequest } = await import("next/server"); + const request = new NextRequest( + `http://localhost/api/teachers/${profileId}/photos/${photoId}`, + { method: "DELETE" }, + ); + const response = await DELETE(request, { + params: Promise.resolve({ id: profileId, photoId }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); + + // Verify deleted from DB + const remaining = await pg.query( + "SELECT id FROM teacher_photos WHERE id = $1", + [photoId], + ); + expect(remaining.rows).toHaveLength(0); + }); + }); +}); diff --git a/docs/testing.md b/docs/testing.md index ec620a6..9b6c143 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,7 @@ npm run test # Run tests for a specific workspace npm run test -w @acroyoga/tokens # 20 token pipeline tests npm run test -w @acroyoga/shared-ui # 85 component tests -npm run test -w @acroyoga/web # 600+ integration & unit tests +npm run test -w @acroyoga/web # 630+ integration & unit tests # Watch mode for development npm run test:watch @@ -30,15 +30,16 @@ apps/web/tests/ │ ├── db.ts # createTestDb(), applyMigrations(), setTestDb(), clearTestDb() │ └── users.ts # seedSampleUsers(), seedSampleUser() ├── integration/ # API route integration tests +│ ├── account/ # GDPR export download │ ├── community/ # Follows, blocks, profiles, threads, GDPR │ ├── events/ # CRUD, RSVP, waitlist, venues, credits │ ├── gdpr/ # Data export │ ├── journeys/ # Cross-feature user journeys -│ ├── payments/ # Stripe Connect, unauthorized access +│ ├── payments/ # Stripe Connect, webhook, callback, auth, status │ ├── permissions/ # Grants, scope hierarchy, audit log │ ├── recurring/ # Event groups, bookings, concessions │ ├── requests/ # Permission request lifecycle -│ ├── teachers/ # Profiles, certifications, reviews +│ ├── teachers/ # Profiles, certifications, reviews, photos │ ├── social-user.test.ts # Social login provisioning │ ├── login-redirect.test.ts │ ├── health.test.ts @@ -191,6 +192,34 @@ const { POST } = await import("../../src/app/api/my-route/route"); const response = await POST(request); ``` +### vi.mock Pattern for Session Control + +For HTTP-level route tests, use `vi.mock` to control `getServerSession`: + +```typescript +import { vi } from "vitest"; + +// Mock session at module level +vi.mock("@/lib/auth/session", () => ({ + getServerSession: vi.fn(), +})); + +import { getServerSession } from "@/lib/auth/session"; +const mockGetServerSession = vi.mocked(getServerSession); + +// In tests — set auth state +mockGetServerSession.mockResolvedValue({ userId: "user-123" }); // authenticated +mockGetServerSession.mockResolvedValue(null); // unauthenticated + +// Call route handler directly +const { GET } = await import("@/app/api/my-route/route"); +const response = await GET(); +expect(response.status).toBe(200); + +// Clean up +afterEach(() => { vi.resetAllMocks(); }); +``` + ### Testing 403 for Unauthorized Callers **Constitution QG-10 requires** every new mutation endpoint to include a test proving 403 for an unauthorized caller: @@ -317,11 +346,11 @@ export default defineConfig({ | Community | 5 | Profiles, threads, reports, GDPR, follows, blocks/mutes | | Permissions | 6 | Grant/revoke, scope hierarchy, unauthorized access, audit log, multi-grant | | Recurring | 4 | Event groups, recurrence, bookings, concessions/capacity | -| Teachers | 5 | Profiles, certifications, event-teachers, applications, reviews | -| Payments | 2 | Stripe Connect, unauthorized access | +| Teachers | 6 | Profiles, certifications, event-teachers, applications, reviews, photo CRUD | +| Payments | 6 | Stripe Connect, webhook signature validation, OAuth callback, connect authorization, status endpoint, unauthorized access | | Requests | 2 | Request lifecycle, unauthorized access | | Auth | 4 | Social user provisioning, login redirect, account linking, GDPR social | -| GDPR | 1 | Data export | +| GDPR | 2 | Data export, export download (ownership, status validation) | | Explorer | 3 | Calendar views, category filter, responsive layout | | Journeys | 2 | Friend visibility, blocked user visibility | | Health | 1 | Health check endpoints | diff --git a/specs/014-internationalisation/data-model.md b/specs/014-internationalisation/data-model.md new file mode 100644 index 0000000..75dde8b --- /dev/null +++ b/specs/014-internationalisation/data-model.md @@ -0,0 +1,162 @@ +# Data Model: Internationalisation (i18n) + +**Spec**: 014 | **Date**: 2026-04-04 + +## Overview + +The internationalisation feature introduces **no new database tables**. It is a purely client-side and build-time concern. Translation files are static JSON assets. Locale preference is stored in a browser cookie. Formatting helpers use the browser's built-in `Intl` APIs. + +## Entity Relationship Overview + +``` + ┌──────────────────────────┐ + │ Locale Configuration │ + │ (next-intl config) │ + └────────┬─────────────────┘ + │ + ┌──────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌───────────────┐ ┌─────────────┐ ┌──────────────┐ + │ Translation │ │ Formatting │ │ Locale │ + │ Files (JSON) │ │ Helpers │ │ Switcher UI │ + │ messages/*.json│ │ (Intl API) │ │ │ + └───────┬───────┘ └──────┬──────┘ └──────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────────────────────────────────────────┐ + │ React Components (UI Layer) │ + │ useTranslations() hook → translated string │ + │ formatDate() / formatCurrency() → formatted value │ + │ dir="rtl" / dir="ltr" → layout direction │ + └───────────────────────────────────────────────────┘ +``` + +## 1. Translation File Schema + +Each locale has a JSON file in `apps/web/messages/` with a flat namespace hierarchy. + +| Namespace | Purpose | Example Keys | +|-----------|---------|-------------| +| `common` | Shared UI elements | `common.loading`, `common.error`, `common.save`, `common.cancel` | +| `events` | Event-related strings | `events.rsvp`, `events.free`, `events.capacity`, `events.waitlist` | +| `community` | Social features | `community.follow`, `community.block`, `community.report` | +| `permissions` | Admin/role strings | `permissions.grantSuccess`, `permissions.revokeConfirm` | +| `teachers` | Teacher profiles | `teachers.verified`, `teachers.certification`, `teachers.review` | +| `payments` | Payment strings | `payments.connectButton`, `payments.connected` | +| `directory` | User directory | `directory.search`, `directory.filter`, `directory.noResults` | +| `explorer` | Events explorer | `explorer.calendar`, `explorer.map`, `explorer.tree` | +| `auth` | Authentication | `auth.login`, `auth.register`, `auth.logout` | +| `errors` | Error messages | `errors.notFound`, `errors.unauthorized`, `errors.serverError` | + +### File Format + +```json +{ + "common": { + "loading": "Loading…", + "error": "Something went wrong", + "save": "Save", + "cancel": "Cancel", + "actions": "Actions", + "networkError": "Network error — please try again" + }, + "events": { + "free": "Free", + "rsvp": "RSVP", + "capacity": "{current} / {max} spots", + "waitlist": "Join waitlist", + "date": "{date, date, medium}", + "time": "{time, time, short}" + } +} +``` + +**ICU MessageFormat** is used for parameterized strings (e.g., `{current} / {max} spots`). + +## 2. Locale Configuration + +| Field | Type | Default | Notes | +|-------|------|---------|-------| +| `defaultLocale` | `SupportedLocale` | `"en"` | English — always available as fallback | +| `supportedLocales` | `SupportedLocale[]` | `["en", "es"]` | Extensible by adding JSON file + entry | +| `localeDirection` | `Record` | `{ en: "ltr", es: "ltr" }` | RTL for Arabic, Hebrew, etc. | +| `localeDetectionOrder` | `string[]` | `["cookie", "header", "default"]` | Cookie first, then Accept-Language, then default | + +### Type Definition + +```typescript +// packages/shared/src/types/i18n.ts +export type SupportedLocale = "en" | "es"; // Extend as locales are added +export type Direction = "ltr" | "rtl"; + +export interface LocaleConfig { + defaultLocale: SupportedLocale; + supportedLocales: readonly SupportedLocale[]; + localeDirection: Record; +} +``` + +## 3. Formatting Helper Signatures + +```typescript +// packages/shared/src/utils/format.ts + +/** Format a date with locale and timezone awareness */ +export function formatEventDate( + date: string | Date, + options?: { + locale?: string; + timeZone?: string; + style?: "full" | "long" | "medium" | "short"; + includeTime?: boolean; + } +): string; + +/** Format currency with ISO 4217 validation */ +export function formatCurrency( + amount: number, + currencyCode: string, + locale?: string +): string; + +/** Format relative time (e.g., "3 hours ago") */ +export function formatRelativeTime( + date: string | Date, + locale?: string +): string; + +/** Format a number with locale-aware separators */ +export function formatNumber( + value: number, + options?: Intl.NumberFormatOptions & { locale?: string } +): string; +``` + +## 4. Cookie Schema + +| Cookie Name | Value | Max-Age | Path | Notes | +|-------------|-------|---------|------|-------| +| `NEXT_LOCALE` | `SupportedLocale` (e.g., `"es"`) | 1 year | `/` | Standard `next-intl` cookie name | + +## 5. Migration from Existing `translations.ts` + +The existing `packages/shared/src/utils/translations.ts` module contains 41 strings across 7 categories. These will be: + +1. **Migrated** into `apps/web/messages/en.json` under their respective namespaces +2. **Re-exported** as translation key constants for backward compatibility +3. **Deprecated** once all consumers use `useTranslations()` hook directly + +### Mapping + +| Current Category | New Namespace | Example | +|-----------------|---------------|---------| +| `translations.roles.*` | `permissions.roles.*` | `permissions.roles.globalAdmin` | +| `translations.scopes.*` | `permissions.scopes.*` | `permissions.scopes.global` | +| `translations.permissions.*` | `permissions.*` | `permissions.grantSuccess` | +| `translations.requests.*` | `permissions.requests.*` | `permissions.requests.submitSuccess` | +| `translations.payments.*` | `payments.*` | `payments.connectButton` | +| `translations.common.*` | `common.*` | `common.loading` | + +## Database Changes + +**None.** This spec introduces no database tables or migrations. Locale preference is stored client-side in a cookie. All translation data is static JSON served as part of the Next.js build. diff --git a/specs/014-internationalisation/plan.md b/specs/014-internationalisation/plan.md new file mode 100644 index 0000000..47d5779 --- /dev/null +++ b/specs/014-internationalisation/plan.md @@ -0,0 +1,128 @@ +# Implementation Plan: Internationalisation (i18n) + +**Branch**: `014-internationalisation` | **Date**: 2026-04-04 | **Spec**: [specs/014-internationalisation/spec.md](spec.md) +**Input**: Feature specification from `/specs/014-internationalisation/spec.md` +**Status**: Draft + +## Summary + +Adopt `next-intl` as the i18n framework for locale-aware routing, translation lookup, and formatting. Extract all hardcoded UI strings (~200+ instances across 50+ component files) into structured JSON translation files. Replace 24+ `toLocaleDateString()`/`toLocaleString()` calls with shared `Intl.DateTimeFormat` formatting helpers. Convert CSS spacing to Tailwind logical properties for RTL support. Upgrade the CI i18n lint gate from warning to blocking. Deliver a locale switcher component and a documented community translation workflow. + +## Technical Context + +**Language/Version**: TypeScript 5.9 (strict mode), React 19, Next.js 16 (App Router) +**Primary Dependencies**: `next-intl` (i18n framework for Next.js App Router), existing `@acroyoga/shared` translations module +**Storage**: Translation JSON files in `apps/web/messages/` directory; locale preference in cookie +**Testing**: Vitest (unit tests for formatting helpers), integration tests for locale switching, CI lint for string extraction +**Target Platform**: Web (browsers), with shared formatting utilities available to future mobile app +**Project Type**: Cross-cutting horizontal concern affecting all existing UI code +**Performance Goals**: No increase in initial bundle size; non-default locale files lazy-loaded; translation lookup <1ms +**Constraints**: Must not break any existing tests (740 tests); must not change API contracts; RTL structural support without visual regression + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. API-First Design | ✅ PASS | No new API routes. Translation files are static assets served by Next.js. Locale preference stored in cookie (no API needed). | +| II. Test-First Development | ✅ PASS | Unit tests for formatting helpers (formatDate, formatCurrency, formatRelativeTime). Integration tests for locale switching. CI lint upgraded to blocking. | +| III. Privacy & Data Protection | ✅ PASS | Locale preference is not PII. No user data changes. | +| IV. Server-Side Authority | ✅ PASS | Server-side rendering respects `Accept-Language` header and locale cookie. `lang` and `dir` attributes set server-side. | +| V. UX Consistency | ✅ PASS | All text sourced from translation system ensures consistency. RTL layout support via CSS logical properties. | +| VI. Performance Budget | ✅ PASS | Only the active locale's translation file is loaded. `next-intl` tree-shakes unused formatters. No impact on <200KB JS budget. | +| VII. Simplicity | ✅ PASS | `next-intl` is the de facto i18n library for Next.js App Router — maintained, well-documented, zero-config for basic usage. | +| VIII. Internationalisation | ✅ PASS | **This spec implements all Principle VIII constraints**: extractable strings, `Intl.DateTimeFormat`, `Intl.NumberFormat`, RTL structural support, CI lint enforcement. | +| IX. Scoped Permissions | N/A | No permission changes. | +| X. Notification Architecture | N/A | Notification template i18n deferred to Spec 015. | +| XI. Resource Ownership | N/A | No resource mutations. | +| XII. Financial Integrity | ✅ PASS | Currency formatting uses `Intl.NumberFormat` with ISO 4217 codes — validated by formatting helper. | +| QG-5: Bundle Size | ✅ PASS | Per-locale JSON files loaded on demand. `next-intl` adds ~5KB gzipped to shared bundle. | +| QG-9: i18n Compliance | ✅ PASS | CI lint upgraded from warning to blocking (exit 1). ESLint plugin added for per-file enforcement. | + +**Gate result: PASS — no violations. Proceed to implementation.** + +## Project Structure + +### Documentation (this feature) + +```text +specs/014-internationalisation/ +├── spec.md # Feature specification +├── plan.md # This file — implementation plan +├── tasks.md # Dependency-ordered implementation tasks +└── data-model.md # Translation file schema and locale configuration +``` + +### Source Code (repository root) + +```text +apps/web/ +├── messages/ # NEW — Translation JSON files +│ ├── en.json # Default locale (English) +│ └── es.json # Example second locale (Spanish) +├── src/ +│ ├── i18n/ # NEW — next-intl configuration +│ │ ├── request.ts # Server-side locale resolution +│ │ ├── routing.ts # Locale-aware routing config +│ │ └── navigation.ts # Locale-aware Link, redirect, etc. +│ ├── app/ +│ │ └── [locale]/ # MODIFIED — locale prefix in route segments +│ │ └── layout.tsx # MODIFIED — NextIntlClientProvider, lang/dir attrs +│ ├── components/ +│ │ └── LocaleSwitcher.tsx # NEW — locale selection dropdown +│ └── lib/ +│ └── i18n/ +│ ├── translations.ts # EXISTING — MODIFIED to re-export from next-intl +│ └── format.ts # NEW — shared Intl formatting helpers +│ +├── next.config.js # MODIFIED — next-intl plugin integration + +packages/shared/src/ +├── utils/ +│ ├── translations.ts # EXISTING — MODIFIED to export translation keys as constants +│ └── format.ts # NEW — platform-agnostic Intl formatting helpers +│ +├── types/ +│ └── i18n.ts # NEW — Locale, Direction, TranslationNamespace types + +packages/shared-ui/src/ +├── EventCard/index.web.tsx # MODIFIED — replace hardcoded "Free" with translation key +├── ProfileCompleteness/*.tsx # MODIFIED — replace hardcoded labels with translation keys +├── OfflineBanner/index.web.tsx # MODIFIED — use translation key for offline message +├── DirectoryCard/index.web.tsx # MODIFIED — replace hardcoded strings with translation keys +└── ... (all 17 components audited) + +scripts/ +└── lint-i18n.sh # MODIFIED — exit 1 instead of exit 0 +``` + +## Complexity Tracking + +| Concern | Status | Mitigation | +|---------|--------|------------| +| 200+ string extractions | Medium | Phased extraction — shared-ui first, then web components, then pages | +| 24+ date formatting refactors | Low | Single formatting helper; find-and-replace pattern | +| RTL CSS migration | Medium | Tailwind v4 supports logical properties natively (`ms-*`, `me-*`); gradual migration | +| Existing test breakage | Low | Translation keys in tests can use `en.json` values; mock `next-intl` in test setup | +| next-intl + Next.js 16 compat | Low | next-intl supports App Router; verify with Next.js 16 specifically | + +## Phase Breakdown + +### Phase 1: Infrastructure Setup +Install `next-intl`, create translation file structure, configure locale routing, set up formatting helpers. No UI changes yet — purely infrastructure. + +### Phase 2: String Extraction (Shared UI) +Extract hardcoded strings from all 17 shared-ui components into translation keys. Update Storybook stories to use translation provider. + +### Phase 3: String Extraction (Web Components & Pages) +Extract hardcoded strings from web app components and pages. Replace `toLocaleDateString()` calls with formatting helpers. + +### Phase 4: Locale Switcher & RTL +Build the locale switcher component. Add RTL structural support via CSS logical properties. Add a second locale (Spanish) as proof-of-concept. + +### Phase 5: CI & Documentation +Upgrade i18n lint to blocking. Add translation key completeness check. Document the community translation workflow. + +### Phase 6: Polish & Validation +Run full validation checklist. Verify no regressions. Update README and contributing guide. diff --git a/specs/014-internationalisation/spec.md b/specs/014-internationalisation/spec.md new file mode 100644 index 0000000..07b1070 --- /dev/null +++ b/specs/014-internationalisation/spec.md @@ -0,0 +1,142 @@ +# Feature Specification: Internationalisation (i18n) + +**Feature Branch**: `014-internationalisation` +**Created**: 2026-04-04 +**Status**: Draft +**Input**: Constitution Principle VIII mandate, ~20 deferred i18n tasks across Specs 001/003/004/005, existing `translations.ts` module and `lint-i18n.sh` CI gate + +## Summary + +Implement full internationalisation support across the platform. The existing codebase has a centralized `translations.ts` module (41 strings across 7 categories), an i18n CI lint script (warning-only), and partial `Intl` API usage. This spec completes the i18n story by: (1) adopting `next-intl` for locale-aware routing and translation, (2) extracting all hardcoded UI strings into translation files, (3) replacing `toLocaleDateString()`/`toLocaleString()` calls with `Intl.DateTimeFormat`, (4) adding RTL structural support via CSS logical properties, (5) upgrading the CI lint gate from warning to blocking, and (6) delivering a locale switcher UI. The default locale is English (`en`); community-contributed locales can be added by dropping a JSON translation file. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Browse Platform in Default Locale (Priority: P1) + +A community member visits the platform. All UI text, dates, times, currency amounts, and numbers are displayed using their browser's preferred locale settings. No hardcoded English strings appear in the UI — all text comes from the translation system. Date/time values respect the user's timezone. Currency amounts use `Intl.NumberFormat` with proper ISO 4217 formatting. + +**Why this priority**: The constitution mandates i18n-ready strings from day one (Principle VIII). This is the foundational story — every other i18n feature depends on the translation infrastructure being in place. + +**Independent Test**: Load any page. Inspect the DOM for raw English string literals in component output. Verify all visible text traces back to a translation key. Verify dates use `Intl.DateTimeFormat` formatting. Verify currency uses `Intl.NumberFormat`. + +**Acceptance Scenarios**: + +1. **Given** a user visits any page, **When** the page renders, **Then** all user-facing text is sourced from the translation system (no hardcoded strings in JSX). +2. **Given** an event has a start date, **When** displayed on EventCard or EventDetailPage, **Then** the date is formatted via `Intl.DateTimeFormat` respecting the user's locale and timezone. +3. **Given** an event has a cost, **When** displayed, **Then** the amount is formatted via `Intl.NumberFormat` with the correct ISO 4217 currency code. +4. **Given** the CI pipeline runs, **When** the i18n lint step executes, **Then** it exits with code 1 (blocking) on any raw string literal detected in component files. +5. **Given** the translation module, **When** a developer adds a new UI string, **Then** they add it to the translation file and reference it by key — the lint prevents hardcoded alternatives. + +--- + +### User Story 2 - Switch Locale at Runtime (Priority: P2) + +A community member clicks a locale switcher in the site header/footer and selects a different language (e.g., Spanish, French). The page re-renders with all text in the selected locale. The locale preference persists across page navigations and browser sessions via a cookie. Dates, numbers, and currency automatically adapt to the new locale's formatting conventions. + +**Why this priority**: Locale switching is the core value proposition of i18n — without it, the translation infrastructure has no user-facing benefit beyond the default locale. + +**Independent Test**: Load the homepage in English. Click the locale switcher. Select "Español". Verify all text changes to Spanish. Navigate to another page — Spanish persists. Refresh the browser — Spanish still active. + +**Acceptance Scenarios**: + +1. **Given** the site header, **When** a user looks for locale options, **Then** a locale switcher UI element is visible showing the current locale. +2. **Given** the locale switcher, **When** the user selects a different locale, **Then** all UI text re-renders in the selected language without a full page reload. +3. **Given** a locale has been selected, **When** the user navigates to another page, **Then** the selected locale persists. +4. **Given** a locale preference is set, **When** the user closes and reopens the browser, **Then** the previously selected locale is restored from a cookie. +5. **Given** the selected locale is Spanish, **When** a date is displayed, **Then** it uses Spanish date formatting (e.g., "4 de abril de 2026"). + +--- + +### User Story 3 - RTL Layout Support (Priority: P3) + +A community member using an RTL language (Arabic, Hebrew) selects their locale. The entire page layout mirrors — navigation flows right-to-left, text alignment flips, margins/paddings use logical properties. No layout breaks or overlapping elements occur. + +**Why this priority**: Constitution VIII mandates structural RTL support. While no RTL locales may ship immediately, the CSS foundation must be in place so adding an RTL locale requires only a translation file, not layout changes. + +**Independent Test**: Switch to an RTL locale (or force `dir="rtl"` on ``). Verify the navigation, cards, forms, and text all flow correctly right-to-left. Verify no overlapping or broken spacing. + +**Acceptance Scenarios**: + +1. **Given** an RTL locale is active, **When** the page renders, **Then** the `` element has `dir="rtl"` and `lang` set to the locale code. +2. **Given** an RTL layout, **When** inspecting CSS, **Then** all spacing uses logical properties (`margin-inline-start` instead of `margin-left`, etc.) or Tailwind's logical equivalents (`ms-*`, `me-*`, `ps-*`, `pe-*`). +3. **Given** an RTL layout, **When** the user views the navigation, **Then** it flows right-to-left with correct icon/text ordering. +4. **Given** an RTL layout, **When** viewing event cards, **Then** card content and metadata are correctly mirrored. + +--- + +### User Story 4 - Community Translation Contribution (Priority: P3) + +A community contributor wants to add a new language. They create a new JSON translation file following a documented pattern, submit a PR, and the CI validates the file has all required keys. No code changes are needed — only the translation JSON file. + +**Why this priority**: The constitution says "additional locales are added by the community". This story ensures the translation file format is documented and validated. + +**Independent Test**: Copy `en.json` to `pt.json`, translate a few keys, leave some untranslated. Submit a PR. CI reports which keys are missing. Fill in all keys — CI passes. + +**Acceptance Scenarios**: + +1. **Given** a contributor, **When** they read the i18n documentation, **Then** they understand how to create a new locale file. +2. **Given** a new locale file, **When** it is missing required keys, **Then** the CI lint step reports the missing keys. +3. **Given** a complete locale file, **When** added to the translations directory, **Then** the locale appears in the locale switcher without any code changes. + +--- + +### Edge Cases + +- Missing translation key falls back to English (default locale), never shows a raw key like `events.rsvp.confirm` +- Pluralization rules differ by locale (e.g., Arabic has 6 plural forms) — use ICU MessageFormat +- Number formatting for currencies without minor units (e.g., JPY) must not show decimals +- Timezone-aware date formatting must handle DST transitions +- Long translated strings must not break layout (German/Finnish words are significantly longer than English) +- Bi-directional text (e.g., English brand names in Arabic text) must render correctly + +## Requirements + +### Functional Requirements + +- **FR-001**: All user-facing strings MUST be sourced from translation files, not hardcoded in components +- **FR-002**: Date/time formatting MUST use `Intl.DateTimeFormat` with locale and timezone parameters +- **FR-003**: Currency formatting MUST use `Intl.NumberFormat` with ISO 4217 currency codes +- **FR-004**: The `` element MUST have correct `lang` and `dir` attributes based on active locale +- **FR-005**: A locale switcher MUST be accessible from every page +- **FR-006**: Locale preference MUST persist across sessions via cookie +- **FR-007**: CSS MUST use logical properties for spacing/alignment to support RTL +- **FR-008**: CI MUST block PRs that introduce hardcoded string literals in component files +- **FR-009**: Adding a new locale MUST require only a JSON translation file — no code changes +- **FR-010**: Missing translation keys MUST fall back to the default locale (English) + +### Key Entities + +- **Translation files**: JSON files per locale (`en.json`, `es.json`, `fr.json`, etc.) +- **Locale context**: React context providing current locale, direction, and translation function +- **Formatting helpers**: Shared utilities wrapping `Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat` +- **Locale switcher**: UI component for runtime locale selection + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Zero hardcoded user-facing strings detected by CI lint (exit code 1) +- **SC-002**: 100% of date/time displays use `Intl.DateTimeFormat` (zero `toLocaleDateString()` calls remaining) +- **SC-003**: All 17 shared-ui components use translation keys for visible text +- **SC-004**: Locale switcher functional with at least 2 locales (en + 1 other) +- **SC-005**: RTL layout renders correctly when `dir="rtl"` is forced +- **SC-006**: New locale can be added with only a JSON file — verified by test + +## Constitution Compliance + +| Principle | Applicable | Notes | +|-----------|:---:|-------| +| I. API-First | ✅ | Locale preference may be stored server-side for SSR; translation files are static assets | +| II. Test-First | ✅ | Unit tests for formatting helpers; integration tests for locale switching; CI lint for string extraction | +| III. Privacy | N/A | Locale preference is not PII | +| IV. Server-Side Authority | ✅ | Server renders correct `lang`/`dir` attributes; locale detection from Accept-Language header | +| V. UX Consistency | ✅ | All text consistent via translation system; RTL layout mirroring | +| VI. Performance Budget | ✅ | Translation files loaded per-locale (no bundling all locales); lazy-load non-default locales | +| VII. Simplicity | ✅ | Use `next-intl` (maintained, Next.js-native) over custom solution | +| VIII. Internationalisation | ✅ | **Primary spec** — implements all Principle VIII constraints | +| IX. Scoped Permissions | N/A | No permission changes | +| X. Notification Architecture | N/A | Notification i18n deferred to Spec 015 | +| XI. Resource Ownership | N/A | No resource changes | +| XII. Financial Integrity | ✅ | Currency formatting uses `Intl.NumberFormat` with ISO 4217 | +| XIII. Development Environment | ✅ | Translation contributor workflow documented | +| XIV. Managed Identity | N/A | No Azure changes | diff --git a/specs/014-internationalisation/tasks.md b/specs/014-internationalisation/tasks.md new file mode 100644 index 0000000..0b2a15a --- /dev/null +++ b/specs/014-internationalisation/tasks.md @@ -0,0 +1,163 @@ +# Tasks: Internationalisation (i18n) + +**Input**: Design documents from `/specs/014-internationalisation/` +**Prerequisites**: plan.md (required), spec.md (required) + +**Tests**: Constitution mandates test-first development. Tests are included and MUST fail before implementation. + +**Organization**: Tasks are grouped by phase to enable incremental delivery. Each phase builds on the previous. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Translation files**: `apps/web/messages/` +- **i18n config**: `apps/web/src/i18n/` +- **Shared types**: `packages/shared/src/types/` +- **Shared utils**: `packages/shared/src/utils/` +- **Shared UI components**: `packages/shared-ui/src/` +- **Web components**: `apps/web/src/components/` +- **Web pages**: `apps/web/src/app/` +- **Unit tests**: `apps/web/tests/unit/` +- **Integration tests**: `apps/web/tests/integration/` +- **Scripts**: `scripts/` + +--- + +## Phase 1: Infrastructure Setup (US1 — Translation Foundation) + +**Purpose**: Install dependencies, create translation file structure, configure next-intl, build formatting helpers + +### Tests for Phase 1 + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T001 [P] [US1] Unit tests for shared formatting helpers — `formatEventDate()`, `formatCurrency()`, `formatRelativeTime()`, `formatNumber()` with locale and timezone params in `apps/web/tests/unit/i18n-format.test.ts` +- [ ] T002 [P] [US1] Unit tests for translation key completeness validator — verify all keys in `en.json` exist in other locale files, report missing keys in `apps/web/tests/unit/i18n-completeness.test.ts` + +### Implementation for Phase 1 + +- [ ] T003 [US1] Install `next-intl` in `apps/web/package.json`. Verify compatibility with Next.js 16 App Router +- [ ] T004 [P] [US1] Create shared i18n types — `Locale`, `Direction`, `TranslationNamespace`, `SupportedLocale` — in `packages/shared/src/types/i18n.ts`. Re-export from `packages/shared/src/index.ts` +- [ ] T005 [P] [US1] Create shared formatting helpers — `formatEventDate()` (wraps `Intl.DateTimeFormat` with timezone), `formatCurrency()` (wraps `Intl.NumberFormat` with ISO 4217 validation), `formatRelativeTime()` (wraps `Intl.RelativeTimeFormat`), `formatNumber()` — in `packages/shared/src/utils/format.ts` +- [ ] T006 [US1] Create English translation file `apps/web/messages/en.json` with namespace hierarchy: `common`, `events`, `community`, `permissions`, `teachers`, `payments`, `directory`, `explorer`, `auth`, `errors` +- [ ] T007 [US1] Configure next-intl: create `apps/web/src/i18n/request.ts` (server-side locale resolver from cookie/Accept-Language), `apps/web/src/i18n/routing.ts` (supported locales and default locale config), `apps/web/src/i18n/navigation.ts` (locale-aware Link, redirect, usePathname) +- [ ] T008 [US1] Update `apps/web/next.config.js` to integrate `next-intl` plugin with `createNextIntlPlugin` +- [ ] T009 [US1] Update root layout `apps/web/src/app/layout.tsx` — wrap with `NextIntlClientProvider`, set `lang` and `dir` attributes from resolved locale +- [ ] T010 [US1] Migrate existing `packages/shared/src/utils/translations.ts` — export translation key constants (not string values) that map to keys in the JSON files. Maintain backward compatibility with existing imports + +**Checkpoint**: next-intl configured, formatting helpers tested, English translation file created. No UI strings extracted yet — existing behavior unchanged. + +--- + +## Phase 2: String Extraction — Shared UI Components (US1) + +**Purpose**: Extract hardcoded strings from all 17 shared-ui components into translation keys + +### Tests for Phase 2 + +- [ ] T011 [P] [US1] Update shared-ui component tests to verify translation key usage — ensure no raw string output in rendered HTML for `EventCard`, `ProfileCompleteness`, `OfflineBanner`, `DirectoryCard` in existing test files under `packages/shared-ui/src/` + +### Implementation for Phase 2 + +- [ ] T012 [P] [US1] Extract strings from `packages/shared-ui/src/EventCard/index.web.tsx` — replace `"Free"` with translation key `events.free`, replace date formatting with `formatEventDate()` helper +- [ ] T013 [P] [US1] Extract strings from `packages/shared-ui/src/ProfileCompleteness/ProfileCompleteness.tsx` — replace hardcoded labels ("Profile photo", "Display name", "Bio", "Home city", "Social link") with translation keys +- [ ] T014 [P] [US1] Extract strings from `packages/shared-ui/src/OfflineBanner/index.web.tsx` — replace default offline message with translation key `common.offlineMessage` +- [ ] T015 [P] [US1] Extract strings from `packages/shared-ui/src/DirectoryCard/index.web.tsx` — replace "Friends", "Follows you", "Unnamed member" with translation keys +- [ ] T016 [P] [US1] Audit and extract strings from remaining 13 shared-ui components — `CategoryLegend`, `LocationTree`, `BookingCard`, `ConcessionBadge`, `Badge`, `Button`, `Card`, `Dialog`, `Input`, `Select`, `Skeleton`, `Toast`, `Tooltip` +- [ ] T017 [US1] Update Storybook decorator in `apps/web/.storybook/preview.ts` to wrap stories with `NextIntlClientProvider` using `en.json` messages + +**Checkpoint**: All 17 shared-ui components use translation keys. Storybook renders correctly with translation provider. + +--- + +## Phase 3: String Extraction — Web Components & Pages (US1) + +**Purpose**: Extract hardcoded strings from web app components and pages. Replace `toLocaleDateString()` calls with formatting helpers. + +### Tests for Phase 3 + +- [ ] T018 [P] [US1] Integration tests for date formatting in event display — verify `formatEventDate()` output matches expected `Intl.DateTimeFormat` format for event cards, event detail, occurrences in `apps/web/tests/integration/i18n/date-format.test.ts` + +### Implementation for Phase 3 + +- [ ] T019 [P] [US1] Replace all `toLocaleDateString()` / `toLocaleString()` / `toLocaleTimeString()` calls in `apps/web/src/components/events/*.tsx` (15+ files) with `formatEventDate()` from shared formatting helpers +- [ ] T020 [P] [US1] Replace date formatting calls in `apps/web/src/app/**/*.tsx` pages — event detail, event groups, occurrences, discussions, bookings (9+ files) +- [ ] T021 [P] [US1] Extract strings from event components — `EventCard`, `EventFilters`, `ExplorerShell`, `CalendarPanel`, `MapPanel`, `LocationTreePanel`, `EventDetailPage` +- [ ] T022 [P] [US1] Extract strings from community components — discussion pages, thread components, report forms, moderation pages +- [ ] T023 [P] [US1] Extract strings from admin components — permission grant/revoke UI, creator settings, admin dashboard +- [ ] T024 [P] [US1] Extract strings from teacher components — profile pages, review forms, certification display, application forms +- [ ] T025 [P] [US1] Extract strings from payment components — Stripe Connect status, booking confirmation, concession pricing +- [ ] T026 [P] [US1] Extract strings from directory and profile components — user directory, profile page, linked accounts +- [ ] T027 [P] [US1] Extract strings from auth components — login, register, session management pages + +**Checkpoint**: All web components and pages use translation keys. Zero `toLocaleDateString()` calls remaining. + +--- + +## Phase 4: Locale Switcher & RTL Support (US2, US3) + +**Purpose**: Build locale switcher, add second locale, implement RTL structural support + +### Tests for Phase 4 + +- [ ] T028 [P] [US2] Integration tests for locale switcher — verify locale change triggers re-render, cookie persistence, navigation preservation in `apps/web/tests/integration/i18n/locale-switch.test.ts` +- [ ] T029 [P] [US3] Visual regression tests for RTL layout — verify card mirroring, navigation flow, form alignment in `apps/web/tests/integration/i18n/rtl-layout.test.ts` + +### Implementation for Phase 4 + +- [ ] T030 [US2] Create Spanish translation file `apps/web/messages/es.json` — translate all keys from `en.json` as proof-of-concept second locale +- [ ] T031 [US2] Create `LocaleSwitcher` component in `apps/web/src/components/LocaleSwitcher.tsx` — dropdown showing available locales with current locale highlighted, triggers locale change via `next-intl` router +- [ ] T032 [US2] Add `LocaleSwitcher` to site header/navigation layout — visible on all pages, accessible via keyboard +- [ ] T033 [US3] Audit and convert CSS to logical properties — replace `ml-*`/`mr-*` with `ms-*`/`me-*`, `pl-*`/`pr-*` with `ps-*`/`pe-*`, `left-*`/`right-*` with `start-*`/`end-*` in Tailwind classes across all components +- [ ] T034 [US3] Update `apps/web/src/app/layout.tsx` to set `dir` attribute based on locale direction (LTR/RTL) +- [ ] T035 [US3] Create Arabic translation stub `apps/web/messages/ar.json` — minimal translation for RTL testing (not required to be complete) + +**Checkpoint**: Locale switcher functional. Spanish locale fully translated. RTL layout structurally supported. + +--- + +## Phase 5: CI Enforcement & Documentation (US4) + +**Purpose**: Upgrade CI lint to blocking, add translation completeness check, document workflow + +### Tests for Phase 5 + +- [ ] T036 [P] [US4] Test that `scripts/lint-i18n.sh` exits with code 1 on raw string detection in `apps/web/tests/unit/lint-i18n-enforcement.test.ts` + +### Implementation for Phase 5 + +- [ ] T037 [US4] Upgrade `scripts/lint-i18n.sh` — change `exit 0` to `exit 1` for violations. Update scan patterns to cover all component directories including new locale-prefixed routes +- [ ] T038 [P] [US4] Add translation key completeness check to CI — script or test that verifies all non-default locale files have every key present in `en.json`. Report missing keys with file path and key name +- [ ] T039 [P] [US4] Add i18n section to `CONTRIBUTING.md` — document: how to add a new string (add key to en.json, use `useTranslations()` hook), how to add a new locale (copy en.json, translate, add to supported locales), naming conventions for translation keys +- [ ] T040 [P] [US4] Add i18n section to `docs/testing.md` — document: how to test with translations in unit tests, how to mock `next-intl` in integration tests, how to verify RTL layout + +**Checkpoint**: CI blocks on i18n violations. Translation workflow documented. + +--- + +## Phase 6: Polish & Validation (All Stories) + +**Purpose**: Full validation, regression testing, README update + +- [ ] T041 Run full validation checklist: `npm run tokens:build -w @acroyoga/tokens` → `npm run typecheck` → `npm run lint -w @acroyoga/web` → `npm run test` → `npm run build -w @acroyoga/web` +- [ ] T042 Verify Storybook builds and all accessibility audits pass with translation provider +- [ ] T043 Update `README.md` — add Spec 014 to specs table, update i18n description in Architectural Principles section +- [ ] T044 Update deferred i18n tasks in Specs 001, 003, 004, 005 — change rationale from "deferred to i18n sprint" to "addressed by Spec 014" + +**Checkpoint**: All tests pass, CI green, documentation updated. + +--- + +## Dependencies & Execution Order + +- **Phase 1**: No dependencies — can start immediately +- **Phase 2**: Depends on Phase 1 (needs translation file and formatting helpers) +- **Phase 3**: Depends on Phase 1 (needs formatting helpers). Can run in parallel with Phase 2 +- **Phase 4**: Depends on Phases 2–3 (strings must be extracted before locale switching is meaningful) +- **Phase 5**: Depends on Phases 1–3 (lint must cover extracted strings) +- **Phase 6**: Depends on all prior phases diff --git a/specs/015-background-jobs-notifications/data-model.md b/specs/015-background-jobs-notifications/data-model.md new file mode 100644 index 0000000..1ba8d7a --- /dev/null +++ b/specs/015-background-jobs-notifications/data-model.md @@ -0,0 +1,179 @@ +# Data Model: Background Jobs & Notifications + +**Spec**: 015 | **Date**: 2026-04-04 + +## Overview + +This spec introduces 4 new database tables for notification storage, preference management, and job tracking. The `pg-boss` library manages its own schema for job queue persistence (not documented here — see pg-boss docs). + +## Entity Relationship Overview + +``` +┌──────────────┐ ┌──────────────────────┐ ┌──────────────────┐ +│ users │────→│ notifications │ │ notification │ +│ (existing) │ │ (user_id FK) │ │ _preferences │ +└──────────────┘ └──────────────────────┘ │ (user_id FK) │ + │ └──────────────────┘ + │ + │ ┌──────────────────────┐ + └────────────→│ pg-boss schema │ + │ (managed by pg-boss) │ + │ - job │ + │ - schedule │ + │ - archive │ + └──────────────────────┘ +``` + +## New Tables + +### 1. `notifications` + +Stores in-app notification records for each user. + +| Column | Type | Nullable | Default | Notes | +|--------|------|----------|---------|-------| +| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key | +| `user_id` | `uuid` | NOT NULL | — | FK → `users.id` ON DELETE CASCADE | +| `type` | `text` | NOT NULL | — | Notification type enum value | +| `title` | `text` | NOT NULL | — | Short title (i18n-rendered at creation time) | +| `body` | `text` | NULL | — | Optional longer description | +| `resource_type` | `text` | NULL | — | Type of linked resource (event, review, profile, etc.) | +| `resource_id` | `uuid` | NULL | — | ID of linked resource for navigation | +| `read` | `boolean` | NOT NULL | `false` | Read/unread status | +| `created_at` | `timestamptz` | NOT NULL | `now()` | Creation timestamp | + +**Indexes**: +- `idx_notifications_user_id_created_at` on `(user_id, created_at DESC)` — paginated list query +- `idx_notifications_user_id_read` on `(user_id, read)` WHERE `read = false` — unread count query + +### 2. `notification_preferences` + +Stores per-user, per-type, per-channel notification preferences. + +| Column | Type | Nullable | Default | Notes | +|--------|------|----------|---------|-------| +| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key | +| `user_id` | `uuid` | NOT NULL | — | FK → `users.id` ON DELETE CASCADE | +| `notification_type` | `text` | NOT NULL | — | Notification type enum value | +| `channel` | `text` | NOT NULL | — | Channel enum value (in_app, email) | +| `enabled` | `boolean` | NOT NULL | `true` | Whether this type+channel is enabled | +| `updated_at` | `timestamptz` | NOT NULL | `now()` | Last update timestamp | + +**Indexes**: +- `idx_notification_preferences_user` on `(user_id)` — list all preferences for user +- **Unique constraint**: `uq_notification_preferences` on `(user_id, notification_type, channel)` — one preference per type per channel per user + +**Default behavior**: If no preference row exists for a user/type/channel combination, the system treats it as `enabled = true` (opt-out model, not opt-in). + +## Enums + +### NotificationType + +```typescript +export enum NotificationType { + // Events + EVENT_RSVP = "event_rsvp", // Someone RSVPed to your event + WAITLIST_PROMOTION = "waitlist_promotion", // You were promoted from waitlist + EVENT_CANCELLATION = "event_cancellation", // An event you RSVPed to was cancelled + OCCURRENCE_CANCELLATION = "occurrence_cancellation", // A specific occurrence was cancelled + + // Teachers & Reviews + REVIEW_POSTED = "review_posted", // Someone reviewed your teaching + REVIEW_REMINDER = "review_reminder", // Reminder to review a past event + CERT_EXPIRY_WARNING = "cert_expiry_warning", // Your certification expires soon + + // Community + FOLLOW_NEW = "follow_new", // Someone followed you + REPORT_RESOLVED = "report_resolved", // Your content report was resolved + + // Payments + PAYMENT_RECEIVED = "payment_received", // You received a payment for an event +} +``` + +### NotificationChannel + +```typescript +export enum NotificationChannel { + IN_APP = "in_app", // In-app notification (bell icon) + EMAIL = "email", // Email notification + // PUSH = "push", // Push notification (deferred to Spec 016 — mobile) +} +``` + +## Job Types + +### Event-Driven Jobs + +| Job Type | Trigger | Payload | Handler | +|----------|---------|---------|---------| +| `send_notification` | Any notification event | `{ userId, type, data }` | Resolve templates, check preferences, deliver to enabled channels | +| `send_email` | Email channel delivery | `{ to, subject, html, unsubscribeToken }` | Send via Azure Communication Services | + +### Scheduled Jobs (Cron) + +| Job Name | Schedule | Description | +|----------|----------|-------------| +| `review-reminder` | `0 9 * * 1` (Mon 9am UTC) | Find events completed in past week without reviews; send reminders to attendees | +| `cert-expiry-check` | `0 8 * * *` (Daily 8am UTC) | Find certifications expiring within 30 days; send warnings to teachers | +| `waitlist-cleanup` | `0 2 * * *` (Daily 2am UTC) | Remove waitlist entries for events in the past | + +## Migration SQL + +```sql +-- 015-001-notifications.sql + +-- In-app notifications +CREATE TABLE notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type text NOT NULL, + title text NOT NULL, + body text, + resource_type text, + resource_id uuid, + read boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_notifications_user_id_created_at + ON notifications (user_id, created_at DESC); +CREATE INDEX idx_notifications_user_id_unread + ON notifications (user_id) WHERE read = false; + +-- Notification preferences (per user, per type, per channel) +CREATE TABLE notification_preferences ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notification_type text NOT NULL, + channel text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, notification_type, channel) +); + +CREATE INDEX idx_notification_preferences_user + ON notification_preferences (user_id); +``` + +## GDPR Considerations + +The existing `deleteUserData()` function MUST be extended to: +1. Delete all rows from `notifications` where `user_id` matches +2. Delete all rows from `notification_preferences` where `user_id` matches +3. Both tables use `ON DELETE CASCADE` from `users.id`, so user deletion automatically cascades + +## Unsubscribe Token Schema + +Email unsubscribe links use signed tokens to allow preference updates without authentication: + +```typescript +interface UnsubscribeToken { + userId: string; + notificationType: NotificationType; + channel: NotificationChannel; + exp: number; // Expiry timestamp (30 days from email send) +} +``` + +Tokens are signed with `HMAC-SHA256` using the application secret. Verified server-side before updating preferences. diff --git a/specs/015-background-jobs-notifications/plan.md b/specs/015-background-jobs-notifications/plan.md new file mode 100644 index 0000000..6c6c44f --- /dev/null +++ b/specs/015-background-jobs-notifications/plan.md @@ -0,0 +1,122 @@ +# Implementation Plan: Background Jobs & Notifications + +**Branch**: `015-background-jobs-notifications` | **Date**: 2026-04-04 | **Spec**: [specs/015-background-jobs-notifications/spec.md](spec.md) +**Input**: Feature specification from `/specs/015-background-jobs-notifications/spec.md` +**Status**: Draft + +## Summary + +Introduce `pg-boss` as a PostgreSQL-backed job queue for reliable async processing. Create a notification type registry with 10+ notification types across 5 categories. Add `notification_preferences` and `notifications` database tables. Build a job worker process that dequeues and delivers notifications to configured channels. Implement in-app notification storage, a notification bell component, and a preferences management page. Add email delivery via Azure Communication Services with branded templates. Create scheduled jobs for review reminders, certification expiry, and stale waitlist cleanup. + +## Technical Context + +**Language/Version**: TypeScript 5.9 (strict mode), React 19, Next.js 16 (App Router) +**Primary Dependencies**: `pg-boss` (PostgreSQL job queue — zero external infra), Azure Communication Services SDK (email) +**Storage**: PostgreSQL tables for notifications and preferences; pg-boss manages its own schema +**Testing**: Vitest + PGlite for integration tests; mock email delivery; test job processing synchronously +**Target Platform**: Web (API routes + worker process) +**Project Type**: Backend infrastructure + UI components +**Performance Goals**: API response latency unchanged (async enqueue < 5ms); job processing < 30s; email delivery < 60s +**Constraints**: No new infrastructure beyond PostgreSQL; job worker runs as sidecar process in Azure Container Apps; pg-boss compatible with PGlite for testing + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. API-First Design | ✅ PASS | REST endpoints: `GET/PUT /api/notifications/preferences`, `GET /api/notifications`, `POST /api/notifications/:id/read`, `GET /api/admin/jobs` (admin-only) | +| II. Test-First Development | ✅ PASS | Integration tests: job enqueue/dequeue cycle, notification delivery, preference enforcement, scheduled job triggers | +| III. Privacy & Data Protection | ✅ PASS | Email addresses encrypted at rest (existing pattern). Notification content may contain PII — stored encrypted. GDPR deletion includes notifications table | +| IV. Server-Side Authority | ✅ PASS | All job dispatch server-side. Preferences validated by Zod schema. No client-side notification logic | +| V. UX Consistency | ✅ PASS | Notification bell follows existing header component patterns. Preferences page uses existing form components | +| VI. Performance Budget | ✅ PASS | Async enqueue adds < 5ms to API response. Worker process is separate — no impact on web server. Zero additional client JS for job processing | +| VII. Simplicity | ✅ PASS | pg-boss uses existing PostgreSQL — no Redis, RabbitMQ, or external queue. Email via Azure Communication Services (already in Azure ecosystem) | +| VIII. Internationalisation | ✅ PASS | Notification templates use i18n keys. Deferred to Spec 014 for full i18n infrastructure; templates ship with English defaults | +| IX. Scoped Permissions | ✅ PASS | Admin job dashboard uses `withPermission('global_admin')`. Notification preferences use `requireAuth()` (user's own data) | +| X. Notification Architecture | ✅ PASS | **Primary implementation** — enum-driven types, per-user per-channel preferences, async delivery, extensible channels | +| XI. Resource Ownership | ✅ PASS | Notifications owned by recipient user. Preferences owned by user. Admin job view requires admin scope | +| XII. Financial Integrity | N/A | No financial operations | +| QG-5: Bundle Size | ✅ PASS | Notification bell is lightweight (icon + count). pg-boss is server-only — not in client bundle | +| QG-10: Permission Smoke Test | ✅ PASS | 403 tests for admin job dashboard. 401 tests for preferences endpoints | + +**Gate result: PASS — no violations.** + +## Project Structure + +### Documentation + +```text +specs/015-background-jobs-notifications/ +├── spec.md # Feature specification +├── plan.md # This file +├── tasks.md # Dependency-ordered tasks +└── data-model.md # Database schema, notification types, job types +``` + +### Source Code + +```text +apps/web/src/ +├── db/migrations/ +│ └── 015-001-notifications.sql # NEW — notifications, preferences, pg-boss schema +├── lib/ +│ ├── notifications/ # NEW — notification domain +│ │ ├── types.ts # NotificationType enum, Channel enum +│ │ ├── service.ts # enqueueNotification(), getNotifications(), markAsRead() +│ │ ├── preferences.ts # getPreferences(), updatePreferences() +│ │ ├── delivery.ts # Channel delivery adapters (in-app, email) +│ │ └── templates.ts # Notification message templates (i18n-ready) +│ ├── jobs/ # NEW — job infrastructure +│ │ ├── queue.ts # pg-boss initialization and singleton +│ │ ├── worker.ts # Job worker process (dequeue + dispatch) +│ │ ├── scheduled.ts # Cron job definitions +│ │ └── types.ts # Job type definitions +│ └── email/ # NEW — email delivery +│ ├── client.ts # Azure Communication Services client +│ └── templates/ # Email HTML templates +│ ├── base.html # Shared layout with branding +│ ├── notification.html # Generic notification template +│ └── unsubscribe.html # One-click unsubscribe confirmation +├── app/api/ +│ ├── notifications/ +│ │ ├── route.ts # GET — list notifications for current user +│ │ ├── preferences/route.ts # GET/PUT — notification preferences +│ │ └── [id]/read/route.ts # POST — mark notification as read +│ ├── admin/ +│ │ └── jobs/route.ts # GET — job queue status (admin-only) +│ └── unsubscribe/route.ts # GET — one-click email unsubscribe (no auth) +├── components/ +│ ├── NotificationBell.tsx # NEW — header notification icon with count badge +│ └── NotificationPreferences.tsx # NEW — preferences management form +└── app/ + ├── notifications/page.tsx # NEW — notification list page + └── settings/notifications/page.tsx # NEW — notification preferences page + +packages/shared/src/types/ +└── notifications.ts # NEW — NotificationType, NotificationChannel, NotificationPreference types + +scripts/ +└── start-worker.ts # NEW — job worker entry point +``` + +## Phase Breakdown + +### Phase 1: Database & Types +Create migration for notifications and preferences tables. Define notification type enum and channel enum in shared types. + +### Phase 2: Job Queue Infrastructure +Install pg-boss. Create queue initialization, worker process, and job type definitions. Test enqueue/dequeue cycle. + +### Phase 3: In-App Notifications (US1) +Implement notification service, API routes, notification bell component, and notification list page. + +### Phase 4: Preferences (US2) +Implement preferences service, API routes, and preferences management UI. + +### Phase 5: Email Channel (US4) +Implement Azure Communication Services email client, branded templates, and unsubscribe flow. + +### Phase 6: Scheduled Jobs (US5) +Implement cron job definitions for review reminders, cert-expiry checks, and stale waitlist cleanup. + +### Phase 7: Integration & Polish +Connect notification dispatch to existing service functions (RSVP, cancellation, reviews). Run full validation. diff --git a/specs/015-background-jobs-notifications/spec.md b/specs/015-background-jobs-notifications/spec.md new file mode 100644 index 0000000..f3db949 --- /dev/null +++ b/specs/015-background-jobs-notifications/spec.md @@ -0,0 +1,160 @@ +# Feature Specification: Background Jobs & Notifications + +**Feature Branch**: `015-background-jobs-notifications` +**Created**: 2026-04-04 +**Status**: Draft +**Input**: Constitution Principle X mandate, deferred tasks from Specs 003 (notification dispatch) and 005 (review reminders, cert-expiry), existing service functions awaiting async processing + +## Summary + +Implement a background job system and multi-channel notification architecture. The platform has deferred async processing across multiple specs: occurrence cancellation notifications (Spec 003), review reminder scheduling (Spec 005), and certification expiry alerts (Spec 005). Service functions for these operations already exist but are called synchronously. This spec introduces: (1) a job queue using PostgreSQL-backed `pg-boss` for reliable async processing, (2) notification types as an extensible enum with per-user channel preferences, (3) email delivery via Azure Communication Services, (4) in-app notification storage and real-time delivery, and (5) user preference management for notification opt-in/opt-out per type per channel. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Receive In-App Notifications (Priority: P1) + +A community member performs an action that triggers a notification (e.g., someone RSVPs to their event, their waitlist position is promoted, a review is posted on their profile). An in-app notification appears in the notification bell. The notification count badge updates in real time. Clicking a notification navigates to the relevant resource. + +**Why this priority**: In-app notifications are the foundation — they work without any external service configuration and provide immediate value. + +**Independent Test**: Create an event, have another user RSVP. Verify the event creator sees a notification in the bell icon. Click the notification — navigates to the event. Verify the notification is marked as read. + +**Acceptance Scenarios**: + +1. **Given** a user's event receives an RSVP, **When** the creator views the notification bell, **Then** an unread notification appears with the RSVP details. +2. **Given** unread notifications exist, **When** the notification bell renders, **Then** a count badge shows the number of unread notifications. +3. **Given** a notification is displayed, **When** the user clicks it, **Then** they navigate to the relevant resource (event, review, profile). +4. **Given** a notification is clicked, **When** the user returns to the notification list, **Then** the notification is marked as read and the count badge decrements. +5. **Given** multiple notification types, **When** displayed in the list, **Then** each type has a distinct icon and message template. + +--- + +### User Story 2 - Configure Notification Preferences (Priority: P1) + +A community member visits their notification settings page and sees all notification types grouped by category. For each type, they can toggle individual channels (in-app, email) on or off. Preferences are saved immediately and respected by the notification system. + +**Why this priority**: User control over notifications is mandated by Constitution Principle X. Without preferences, notifications would be opt-out-impossible. + +**Independent Test**: Navigate to notification preferences. Toggle off email for "Event RSVP" notifications. Have someone RSVP. Verify in-app notification appears but no email is sent. Toggle email back on — next RSVP triggers both. + +**Acceptance Scenarios**: + +1. **Given** the notification settings page, **When** it loads, **Then** all notification types are listed with toggles for each channel (in-app, email). +2. **Given** a notification type, **When** the user toggles off the email channel, **Then** the preference is saved server-side and no email is sent for that type. +3. **Given** a notification type with email toggled off, **When** the user toggles it back on, **Then** subsequent notifications of that type include email delivery. +4. **Given** default preferences, **When** a new user signs up, **Then** all notification types are enabled for all channels by default. + +--- + +### User Story 3 - Background Job Processing (Priority: P1) + +The system processes asynchronous tasks reliably without blocking API responses. Jobs include: sending notifications, processing review reminders, checking certification expiry, and dispatching occurrence cancellation notices. Failed jobs are retried with exponential backoff. Job status is visible to admins. + +**Why this priority**: Background processing is the infrastructure that enables all async features. Without it, notification delivery blocks API responses (violating Constitution Principle X). + +**Independent Test**: Cancel an event occurrence. Verify the API responds immediately (< 200ms). Verify attendee notifications are delivered asynchronously within 30 seconds. + +**Acceptance Scenarios**: + +1. **Given** an action triggers a notification, **When** the API handler runs, **Then** the notification is enqueued (not sent synchronously) and the API responds within its normal latency. +2. **Given** a job is enqueued, **When** the job worker processes it, **Then** the notification is delivered to all opted-in channels for all affected users. +3. **Given** a job fails, **When** the retry policy applies, **Then** the job is retried with exponential backoff (max 3 retries). +4. **Given** a job permanently fails, **When** max retries are exhausted, **Then** the job is moved to a dead-letter queue and logged. +5. **Given** the admin dashboard, **When** an admin views job status, **Then** they see job queue depth, failure rate, and recent errors. + +--- + +### User Story 4 - Email Notifications (Priority: P2) + +A community member receives email notifications for important events (waitlist promotion, event cancellation, certification expiry warning). Emails are formatted with the platform's branding and include a direct link to the relevant resource. Users can unsubscribe from email notifications via a one-click link. + +**Why this priority**: Email is the primary out-of-app notification channel. It depends on the job queue (US3) and preferences (US2) being in place. + +**Independent Test**: Enable email notifications. Cancel an event. Verify attendees receive a branded email with event details and a link to the platform. + +**Acceptance Scenarios**: + +1. **Given** email is enabled for a notification type, **When** the notification fires, **Then** an email is sent via Azure Communication Services with platform branding. +2. **Given** an email notification, **When** the recipient views it, **Then** it contains a direct link to the relevant resource. +3. **Given** an email notification, **When** the recipient clicks "Unsubscribe", **Then** email is disabled for that notification type (one-click, no login required). +4. **Given** email delivery fails, **When** the job worker detects the failure, **Then** the job is retried per the retry policy. + +--- + +### User Story 5 - Scheduled Jobs (Priority: P2) + +The system runs scheduled tasks on a cron-like schedule: review reminders (weekly), certification expiry checks (daily), and stale waitlist cleanup (daily). Scheduled jobs use the same queue infrastructure as event-driven jobs. + +**Why this priority**: Several deferred tasks (Spec 005 T037, T048) specifically require cron/scheduler functionality. + +**Independent Test**: Configure a daily certification expiry check. Verify it runs at the scheduled time and sends notifications to teachers with certifications expiring within 30 days. + +**Acceptance Scenarios**: + +1. **Given** a scheduled job configuration, **When** the cron time arrives, **Then** the job is enqueued and processed by the job worker. +2. **Given** the review reminder job, **When** it runs, **Then** it identifies events completed in the past week without reviews and sends reminders to attendees. +3. **Given** the cert-expiry job, **When** it runs, **Then** it identifies certifications expiring within 30 days and sends warnings to the teacher. + +--- + +### Edge Cases + +- Job queue must survive server restarts (PostgreSQL-backed persistence) +- Duplicate notification prevention — idempotency keys prevent re-sending on retry +- Rate limiting on email sends to avoid provider throttling +- Timezone-aware scheduling for user-local cron jobs +- Notification templates must be i18n-ready (link to Spec 014) +- Bulk operations (e.g., cancel event with 100 attendees) must enqueue individual jobs, not one mega-job + +## Requirements + +### Functional Requirements + +- **FR-001**: Notifications MUST be enqueued asynchronously — never block the API response +- **FR-002**: Notification types MUST be enum-driven and extensible without code changes to the queue +- **FR-003**: Users MUST be able to opt in/out of each notification type per channel +- **FR-004**: Failed jobs MUST be retried with exponential backoff (max 3 retries) +- **FR-005**: Permanently failed jobs MUST be logged and visible to admins +- **FR-006**: Email notifications MUST include an unsubscribe link that works without login +- **FR-007**: Scheduled jobs MUST use the same queue infrastructure as event-driven jobs +- **FR-008**: In-app notifications MUST track read/unread status per user +- **FR-009**: Notification delivery MUST respect user preferences before sending +- **FR-010**: All notification templates MUST be i18n-extractable (no hardcoded strings) + +### Key Entities + +- **Notification types**: Enum of all triggerable events (RSVP, waitlist, cancellation, review, cert-expiry, etc.) +- **Notification preferences**: Per-user, per-type, per-channel boolean flags +- **Notifications**: Stored in-app notification records with read/unread status +- **Jobs**: Background job records with status, retry count, error details +- **Channels**: Extensible channel registry (in-app, email, push — push deferred to mobile spec) + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Zero synchronous notification sends in API handlers (all enqueued) +- **SC-002**: Job processing latency < 30 seconds from enqueue to delivery +- **SC-003**: All 5 deferred notification/job tasks from Specs 003/005 resolved +- **SC-004**: Notification preferences UI functional with all types and channels +- **SC-005**: Email delivery success rate > 99% (excluding unsubscribes) +- **SC-006**: Scheduled jobs run within 60 seconds of their configured cron time + +## Constitution Compliance + +| Principle | Applicable | Notes | +|-----------|:---:|-------| +| I. API-First | ✅ | Notification preferences and history exposed via REST endpoints | +| II. Test-First | ✅ | Integration tests for job enqueue/process cycle, preference enforcement, email mocking | +| III. Privacy | ✅ | Email addresses used for delivery are PII — encrypted at rest per existing patterns | +| IV. Server-Side Authority | ✅ | Notification dispatch is entirely server-side. Preferences validated by Zod schemas | +| V. UX Consistency | ✅ | Notification bell and preferences page follow existing design token patterns | +| VI. Performance Budget | ✅ | Async processing ensures zero API latency impact. Job worker is a separate process | +| VII. Simplicity | ✅ | `pg-boss` uses existing PostgreSQL — no new infrastructure (Redis, RabbitMQ, etc.) | +| VIII. Internationalisation | ✅ | Notification templates use i18n keys (depends on Spec 014 infrastructure) | +| IX. Scoped Permissions | ✅ | Admin job dashboard requires admin permission via `withPermission()` | +| X. Notification Architecture | ✅ | **Primary spec** — implements all Principle X constraints | +| XI. Resource Ownership | ✅ | Notification preferences are user-owned resources | +| XII. Financial Integrity | N/A | No financial operations | +| XIII. Development Environment | ✅ | Job worker runs in dev via npm script; no external service needed for in-app notifications | +| XIV. Managed Identity | ✅ | Azure Communication Services accessed via Managed Identity | diff --git a/specs/015-background-jobs-notifications/tasks.md b/specs/015-background-jobs-notifications/tasks.md new file mode 100644 index 0000000..62b576d --- /dev/null +++ b/specs/015-background-jobs-notifications/tasks.md @@ -0,0 +1,177 @@ +# Tasks: Background Jobs & Notifications + +**Input**: Design documents from `/specs/015-background-jobs-notifications/` +**Prerequisites**: plan.md (required), spec.md (required) + +**Tests**: Constitution mandates test-first development. Tests are included and MUST fail before implementation. + +**Organization**: Tasks are grouped by phase to enable incremental delivery. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Shared types**: `packages/shared/src/types/` +- **Migrations**: `apps/web/src/db/migrations/` +- **Notification service**: `apps/web/src/lib/notifications/` +- **Job infrastructure**: `apps/web/src/lib/jobs/` +- **Email delivery**: `apps/web/src/lib/email/` +- **API routes**: `apps/web/src/app/api/` +- **Web components**: `apps/web/src/components/` +- **Web pages**: `apps/web/src/app/` +- **Integration tests**: `apps/web/tests/integration/` +- **Scripts**: `scripts/` + +--- + +## Phase 1: Database & Types (Blocking Prerequisites) + +**Purpose**: Create database schema and shared type definitions + +### Tests for Phase 1 + +- [ ] T001 [P] [US1] Integration test for notifications table — insert, query by user, mark as read, cascade delete in `apps/web/tests/integration/notifications/notifications-db.test.ts` +- [ ] T002 [P] [US2] Integration test for preferences table — insert defaults, update per-type per-channel, query by user in `apps/web/tests/integration/notifications/preferences-db.test.ts` + +### Implementation for Phase 1 + +- [ ] T003 [US3] Create migration `apps/web/src/db/migrations/015-001-notifications.sql` with tables: `notification_types` (enum reference), `notification_channels` (enum reference), `notification_preferences` (user_id, type, channel, enabled), `notifications` (id, user_id, type, title, body, resource_type, resource_id, read, created_at), and indexes +- [ ] T004 [P] [US1] Create shared notification types in `packages/shared/src/types/notifications.ts` — `NotificationType` enum (event_rsvp, waitlist_promotion, event_cancellation, occurrence_cancellation, review_posted, review_reminder, cert_expiry_warning, follow_new, report_resolved, payment_received), `NotificationChannel` enum (in_app, email), `NotificationPreference`, `Notification`, `NotificationListResponse` types. Re-export from `packages/shared/src/index.ts` +- [ ] T005 [P] [US3] Create job type definitions in `apps/web/src/lib/jobs/types.ts` — `JobType` enum, `JobPayload` discriminated union, `JobStatus` type + +**Checkpoint**: Database schema created, types defined. No runtime code yet. + +--- + +## Phase 2: Job Queue Infrastructure (US3 — Background Processing) + +**Purpose**: Install pg-boss, create queue initialization, worker process, test enqueue/dequeue + +### Tests for Phase 2 + +- [ ] T006 [P] [US3] Integration test for job enqueue and dequeue — verify job is persisted, worker picks it up, handler is called with correct payload, failed jobs are retried in `apps/web/tests/integration/jobs/queue.test.ts` +- [ ] T007 [P] [US3] Integration test for job retry and dead-letter — verify exponential backoff timing, max retry count, dead-letter queue insertion in `apps/web/tests/integration/jobs/retry.test.ts` + +### Implementation for Phase 2 + +- [ ] T008 [US3] Install `pg-boss` in `apps/web/package.json`. Create queue singleton in `apps/web/src/lib/jobs/queue.ts` — pg-boss initialization with PostgreSQL connection, graceful shutdown handler +- [ ] T009 [US3] Create job worker in `apps/web/src/lib/jobs/worker.ts` — register handlers for each job type, process jobs with error handling, log failures +- [ ] T010 [US3] Create worker entry point script `scripts/start-worker.ts` — initialize pg-boss, register all job handlers, start processing loop +- [ ] T011 [US3] Add `enqueueJob()` helper in `apps/web/src/lib/jobs/queue.ts` — type-safe job enqueue with payload validation, deduplication key support + +**Checkpoint**: Job queue functional — can enqueue and process jobs. Worker runs as separate process. + +--- + +## Phase 3: In-App Notifications (US1) + +**Purpose**: Implement notification service, API routes, bell component, notification list + +### Tests for Phase 3 + +- [ ] T012 [P] [US1] Integration tests for notification service — `createNotification()`, `getNotificationsForUser()`, `markAsRead()`, `getUnreadCount()`, `deleteNotification()` in `apps/web/tests/integration/notifications/service.test.ts` +- [ ] T013 [P] [US1] Integration tests for notification API routes — GET list (auth, pagination), POST mark-as-read (auth, ownership), 403 for other users' notifications in `apps/web/tests/integration/notifications/routes.test.ts` + +### Implementation for Phase 3 + +- [ ] T014 [US1] Implement notification service in `apps/web/src/lib/notifications/service.ts` — `createNotification()` (insert + enqueue delivery job), `getNotificationsForUser()` (paginated, sorted by created_at desc), `markAsRead()`, `getUnreadCount()`, `deleteNotificationsForUser()` (GDPR deletion) +- [ ] T015 [P] [US1] Implement in-app delivery adapter in `apps/web/src/lib/notifications/delivery.ts` — `deliverInApp()` (insert into notifications table — already done by createNotification), `deliverEmail()` (stub, implemented in Phase 5) +- [ ] T016 [US1] Create API routes: `GET /api/notifications` (list for current user, paginated), `POST /api/notifications/:id/read` (mark as read, ownership check) in `apps/web/src/app/api/notifications/` +- [ ] T017 [US1] Create notification message templates in `apps/web/src/lib/notifications/templates.ts` — i18n-ready template functions for each notification type, returning title + body + resource link +- [ ] T018 [US1] Create `NotificationBell` component in `apps/web/src/components/NotificationBell.tsx` — bell icon with unread count badge, dropdown showing recent notifications, click to navigate to resource +- [ ] T019 [US1] Create notification list page `apps/web/src/app/notifications/page.tsx` — full notification history with infinite scroll, read/unread filter, mark-all-as-read action +- [ ] T020 [US1] Add `NotificationBell` to site header layout — visible on all authenticated pages + +**Checkpoint**: In-app notifications functional end-to-end. Users see notifications in bell and list page. + +--- + +## Phase 4: Notification Preferences (US2) + +**Purpose**: Implement preferences service, API routes, and preferences management UI + +### Tests for Phase 4 + +- [ ] T021 [P] [US2] Integration tests for preferences service — `getPreferences()` (defaults on first access), `updatePreference()` (toggle per type per channel), `getEnabledChannels()` (filter channels for a notification type) in `apps/web/tests/integration/notifications/preferences-service.test.ts` +- [ ] T022 [P] [US2] Integration tests for preferences API routes — GET (auth), PUT (auth, validation), 403 for other users' preferences in `apps/web/tests/integration/notifications/preferences-routes.test.ts` + +### Implementation for Phase 4 + +- [ ] T023 [US2] Implement preferences service in `apps/web/src/lib/notifications/preferences.ts` — `getPreferences()` (return all types × channels with defaults), `updatePreference()` (upsert per type per channel), `getEnabledChannels()` (query enabled channels for a notification type and user) +- [ ] T024 [US2] Create API routes: `GET /api/notifications/preferences` (current user's preferences), `PUT /api/notifications/preferences` (update with Zod validation) in `apps/web/src/app/api/notifications/preferences/` +- [ ] T025 [US2] Create `NotificationPreferences` component in `apps/web/src/components/NotificationPreferences.tsx` — grouped by category (Events, Community, Teachers, Payments), toggle per type per channel +- [ ] T026 [US2] Create preferences page `apps/web/src/app/settings/notifications/page.tsx` — settings layout with NotificationPreferences component +- [ ] T027 [US2] Integrate preferences check into notification delivery — before delivering to any channel, query user preferences and skip disabled channels + +**Checkpoint**: Users can manage notification preferences. Delivery respects preferences. + +--- + +## Phase 5: Email Channel (US4) + +**Purpose**: Implement email delivery via Azure Communication Services + +### Tests for Phase 5 + +- [ ] T028 [P] [US4] Integration tests for email delivery — mock Azure Communication Services client, verify email construction, verify unsubscribe link generation, verify preference check in `apps/web/tests/integration/notifications/email-delivery.test.ts` +- [ ] T029 [P] [US4] Integration tests for unsubscribe route — verify one-click unsubscribe updates preferences, works without authentication, returns confirmation page in `apps/web/tests/integration/notifications/unsubscribe.test.ts` + +### Implementation for Phase 5 + +- [ ] T030 [US4] Create email client in `apps/web/src/lib/email/client.ts` — Azure Communication Services SDK initialization with Managed Identity, `sendEmail()` function +- [ ] T031 [P] [US4] Create email templates in `apps/web/src/lib/email/templates/` — `base.html` (branded layout with header, footer, unsubscribe link), `notification.html` (generic notification with title, body, action button) +- [ ] T032 [US4] Implement email delivery adapter in `apps/web/src/lib/notifications/delivery.ts` — `deliverEmail()` renders template with notification data, sends via email client, includes signed unsubscribe link +- [ ] T033 [US4] Create unsubscribe route `apps/web/src/app/api/unsubscribe/route.ts` — verify signed token, update notification preferences, return confirmation HTML page. No authentication required (email link access) + +**Checkpoint**: Email notifications delivered via Azure Communication Services. One-click unsubscribe functional. + +--- + +## Phase 6: Scheduled Jobs (US5) + +**Purpose**: Implement cron job definitions for periodic tasks + +### Tests for Phase 6 + +- [ ] T034 [P] [US5] Integration tests for scheduled jobs — verify review reminder identifies eligible events, cert-expiry identifies expiring certs, waitlist cleanup removes stale entries in `apps/web/tests/integration/jobs/scheduled.test.ts` + +### Implementation for Phase 6 + +- [ ] T035 [US5] Create scheduled job definitions in `apps/web/src/lib/jobs/scheduled.ts` — `reviewReminderJob` (weekly: find events completed in past week without reviews, enqueue reminder notifications), `certExpiryJob` (daily: find certifications expiring within 30 days, enqueue warning notifications), `waitlistCleanupJob` (daily: remove stale waitlist entries for past events) +- [ ] T036 [US5] Register scheduled jobs in worker startup — configure pg-boss cron schedules in `scripts/start-worker.ts` +- [ ] T037 [US5] Create admin job dashboard route `GET /api/admin/jobs` in `apps/web/src/app/api/admin/jobs/route.ts` — queue depth, processing count, failure rate, recent errors. Protected by `withPermission('global_admin')` + +**Checkpoint**: Scheduled jobs configured and running. Admin can monitor job queue health. + +--- + +## Phase 7: Integration & Polish + +**Purpose**: Connect notification dispatch to existing services, update deferred tasks, run validation + +- [ ] T038 [US1] Add notification dispatch calls to existing event service — RSVP (notify creator), waitlist promotion (notify promoted user), event cancellation (notify attendees) in `apps/web/src/lib/events/service.ts` +- [ ] T039 [P] [US1] Add notification dispatch to community service — new follower notification in `apps/web/src/lib/community/service.ts` +- [ ] T040 [P] [US1] Add notification dispatch to teacher review service — review posted notification in `apps/web/src/lib/teachers/reviews.ts` +- [ ] T041 [US1] Add notification dispatch to recurring event service — occurrence cancellation notification in `apps/web/src/lib/events/recurring.ts` +- [ ] T042 Run full validation checklist: `npm run tokens:build -w @acroyoga/tokens` → `npm run typecheck` → `npm run lint -w @acroyoga/web` → `npm run test` → `npm run build -w @acroyoga/web` +- [ ] T043 Update `README.md` — add Spec 015 to specs table +- [ ] T044 Update deferred tasks in Specs 003 and 005 — change rationale from "deferred to notifications sprint" / "deferred to background jobs sprint" to "addressed by Spec 015" +- [ ] T045 Add notification-related GDPR deletion to existing `deleteUserData()` function — delete notifications, preferences, and job references for user + +**Checkpoint**: All deferred notification/job tasks resolved. Full validation passes. + +--- + +## Dependencies & Execution Order + +- **Phase 1**: No dependencies — can start immediately +- **Phase 2**: Depends on Phase 1 (needs migration and types) +- **Phase 3**: Depends on Phase 2 (needs job queue for async delivery) +- **Phase 4**: Depends on Phase 3 (needs notification service) +- **Phase 5**: Depends on Phases 3–4 (needs delivery pipeline and preferences) +- **Phase 6**: Depends on Phase 2 (needs job queue infrastructure) +- **Phase 7**: Depends on all prior phases diff --git a/specs/016-mobile-app/plan.md b/specs/016-mobile-app/plan.md new file mode 100644 index 0000000..8343892 --- /dev/null +++ b/specs/016-mobile-app/plan.md @@ -0,0 +1,131 @@ +# Implementation Plan: Mobile App (Expo/React Native) + +**Branch**: `016-mobile-app` | **Date**: 2026-04-04 | **Spec**: [specs/016-mobile-app/spec.md](spec.md) +**Input**: Feature specification from `/specs/016-mobile-app/spec.md` +**Status**: Draft + +## Summary + +Scaffold an Expo React Native app in `apps/mobile/` consuming the existing REST API, shared types, shared-ui components (`.native.tsx` entry points), and design tokens. Implement JWT authentication with secure storage, TanStack Query data fetching with MMKV offline persistence, Expo Router 5-tab navigation, and push notification integration. This is the largest spec — estimated 28+ tasks from Spec 008's deferred Phase 6, plus additional tasks for features added since (Spec 009–013). + +## Technical Context + +**Language/Version**: TypeScript 5.9 (strict mode), React Native 0.76+, Expo SDK 52+ +**Primary Dependencies**: Expo (managed workflow), Expo Router (file-based navigation), TanStack Query (data fetching), MMKV (offline storage), expo-secure-store (JWT storage), expo-notifications (push) +**Storage**: MMKV for offline cache (50MB limit, LRU eviction), SecureStore for JWT +**Testing**: Jest + React Native Testing Library (unit), Detox (E2E on simulators) +**Target Platform**: iOS 16+, Android 13+ (API 33+) +**Project Type**: Mobile application (React Native via Expo, monorepo workspace) +**Performance Goals**: 60fps scrolling, cold start < 3s, TanStack Query cache reads < 10ms via MMKV +**Constraints**: No native module ejection (Expo managed workflow only); reuse shared packages; JWT auth (not session cookies); design tokens from `@acroyoga/tokens` + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. API-First Design | ✅ PASS | Consumes existing REST API. One new endpoint: `POST /api/auth/mobile-token` (JWT issuance from session). All shared types from `@acroyoga/shared`. | +| II. Test-First Development | ✅ PASS | Jest unit tests for hooks, API client, auth. Detox E2E for critical flows. | +| III. Privacy & Data Protection | ✅ PASS | JWT in SecureStore (Keychain/Keystore). MMKV cache contains public data only. EXIF stripping via existing server-side pipeline. | +| IV. Server-Side Authority | ✅ PASS | Mobile is a thin client. All business rules enforced by API. No client-side validation beyond UX. | +| V. UX Consistency | ✅ PASS | Shared design tokens (Swift/Kotlin/TS outputs). 17 shared-ui components with `.native.tsx` entry points. | +| VI. Performance Budget | ✅ PASS | FlatList with windowSize. Lazy screen loading. MMKV for fast cache reads. Hermes JS engine for fast startup. | +| VII. Simplicity | ✅ PASS | Expo managed workflow — no ejection, no native code. Shared packages reduce code duplication. | +| VIII. Internationalisation | ✅ PASS | Import translations from `@acroyoga/shared`. Use React Native's `Intl` polyfill (Hermes) for formatting. | +| IX. Scoped Permissions | N/A | Permissions enforced server-side. | +| X. Notification Architecture | ✅ PASS | Push notifications as new channel in Spec 015 preferences. expo-notifications for delivery. | +| XI. Resource Ownership | N/A | Enforced server-side. | +| XII. Financial Integrity | ✅ PASS | Payments via in-app browser (Stripe web checkout). No in-app purchase integration. | +| QG-5: Bundle Size | ✅ PASS | Expo manages JS bundle splitting. Hermes bytecode reduces parse time. | + +**Gate result: PASS — no violations.** + +## Project Structure + +### Documentation + +```text +specs/016-mobile-app/ +├── spec.md # Feature specification +├── plan.md # This file +└── tasks.md # Dependency-ordered tasks +``` + +### Source Code + +```text +apps/mobile/ # NEW — Expo React Native app +├── app/ # Expo Router file-based routes +│ ├── _layout.tsx # Root layout (auth gate, providers) +│ ├── (auth)/ # Auth group (login, register) +│ │ ├── _layout.tsx +│ │ └── login.tsx +│ ├── (tabs)/ # Authenticated tab group +│ │ ├── _layout.tsx # Bottom tab bar configuration +│ │ ├── index.tsx # Home tab (event feed) +│ │ ├── events/ # Events tab (search/explore) +│ │ │ ├── _layout.tsx # Stack navigator +│ │ │ ├── index.tsx # Event list +│ │ │ └── [id].tsx # Event detail +│ │ ├── teachers/ # Teachers tab +│ │ │ ├── _layout.tsx +│ │ │ ├── index.tsx +│ │ │ └── [id].tsx +│ │ ├── bookings/ # Bookings tab +│ │ │ └── index.tsx +│ │ └── profile/ # Profile tab +│ │ ├── index.tsx +│ │ └── settings/ +│ │ └── notifications.tsx +│ └── +not-found.tsx +├── lib/ # Mobile-specific utilities +│ ├── auth.ts # JWT management, SecureStore +│ ├── api-client.ts # Typed HTTP client with JWT headers +│ ├── offline.ts # MMKV cache, TanStack Query persister +│ └── connectivity.ts # Network status hook (NetInfo) +├── app.json # Expo app configuration +├── eas.json # EAS Build configuration +├── metro.config.js # Metro bundler config (monorepo support) +├── tsconfig.json # TypeScript config (extends base) +├── package.json # Workspace package +└── __tests__/ # Unit tests (Jest) + +apps/web/src/app/api/auth/ +└── mobile-token/route.ts # NEW — JWT issuance endpoint + +packages/shared/src/ +├── hooks/ # NEW — Shared data fetching hooks +│ ├── useEvents.ts # TanStack Query hook for events +│ ├── useTeachers.ts # TanStack Query hook for teachers +│ └── useOnlineStatus.ts # Cross-platform connectivity hook +└── types/ + └── auth.ts # MODIFIED — add MobileTokenResponse type +``` + +## Phase Breakdown + +### Phase 1: Scaffolding & Configuration +Scaffold Expo app, configure Metro for monorepo, set up TypeScript, configure EAS Build. + +### Phase 2: Authentication +Implement JWT auth flow, SecureStore, API client with auth headers, mobile-token API endpoint. + +### Phase 3: Navigation & Layout +Set up Expo Router with 5-tab bottom navigation, nested stack navigators, platform-specific transitions. + +### Phase 4: Core Screens (US1, US2) +Build Home feed, Events list/detail, RSVP flow. Validate shared-ui components render on native. + +### Phase 5: Remaining Screens +Build Teachers list/detail, Bookings list, Profile page, Settings. + +### Phase 6: Offline Support (US5) +Integrate MMKV persistence with TanStack Query, add connectivity monitoring, offline banner. + +### Phase 7: Push Notifications (US6) +Integrate expo-notifications, register device token with server, handle notification taps for deep linking. + +### Phase 8: Testing & Polish +Jest unit tests, Detox E2E, performance profiling (60fps), CI integration with EAS Build. + +### Phase 9: Platform-Specific Optimization +iOS: back swipe gestures, haptic feedback, Dynamic Type. Android: material transitions, back button handling, edge-to-edge. diff --git a/specs/016-mobile-app/spec.md b/specs/016-mobile-app/spec.md new file mode 100644 index 0000000..45edda0 --- /dev/null +++ b/specs/016-mobile-app/spec.md @@ -0,0 +1,178 @@ +# Feature Specification: Mobile App (Expo/React Native) + +**Feature Branch**: `016-mobile-app` +**Created**: 2026-04-04 +**Status**: Draft +**Input**: Spec 008 Phase 6 deferred mobile tasks (T051–T079), existing shared design token pipeline (CSS/TS/Swift/Kotlin), 17 cross-platform shared-ui components, Constitution Principle V (UX Consistency) + +## Summary + +Build native iOS and Android mobile applications using Expo and React Native. The platform's monorepo already includes a shared design token pipeline that outputs Swift and Kotlin values, 17 cross-platform UI components with `.native.tsx` entry points, and shared TypeScript types. This spec delivers: (1) Expo app scaffolding in `apps/mobile/`, (2) 5-tab navigation (Home, Events, Teachers, Bookings, Profile), (3) JWT-based mobile authentication, (4) TanStack Query data fetching with MMKV offline persistence, (5) native navigation transitions, and (6) platform-specific optimizations for iOS and Android. This is the largest spec in the project — it completes the cross-platform vision established in Spec 008. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Browse Events on Mobile (Priority: P1) + +A community member opens the mobile app and sees a feed of upcoming events. They can scroll through events, filter by category, and tap into event details. The experience feels native — smooth 60fps scrolling, platform-appropriate transitions, and responsive layout that adapts to device screen size. + +**Why this priority**: Event browsing is the core use case. Delivering it on mobile validates the entire cross-platform stack (shared types, tokens, components, API). + +**Independent Test**: Install the app on iOS simulator and Android emulator. Scroll through events. Tap a category filter. Tap an event — see detail page with native transition animation. Verify 60fps scrolling. + +**Acceptance Scenarios**: + +1. **Given** the app launches, **When** events exist, **Then** a scrollable list of events is displayed using native FlatList with 60fps performance. +2. **Given** events are displayed, **When** the user taps a category filter, **Then** the list updates to show only events in that category. +3. **Given** an event is visible, **When** the user taps it, **Then** a detail page opens with a native push animation (iOS) or material transition (Android). +4. **Given** the event detail page, **When** the user views it, **Then** event title, date, location, capacity, and description are displayed using shared-ui components. +5. **Given** the user is offline, **When** they open the app, **Then** cached events are displayed with an offline banner. + +--- + +### User Story 2 - RSVP to Events on Mobile (Priority: P1) + +A community member taps the RSVP button on an event detail page. They select their role (Base/Flyer/Hybrid) and confirm. The RSVP is submitted to the API. If the event is full, they can join the waitlist. The booking appears in their Bookings tab. + +**Why this priority**: RSVP is the primary conversion action — users find events to attend them. This validates the mutation flow (auth, API, state update). + +**Independent Test**: Log in to the app. Find an event with available spots. Tap RSVP, select a role, confirm. Verify the RSVP appears in the Bookings tab. Verify the event capacity updates. + +**Acceptance Scenarios**: + +1. **Given** an event with available spots, **When** the user taps RSVP, **Then** a role selection sheet appears (Base/Flyer/Hybrid). +2. **Given** a role is selected, **When** the user confirms, **Then** the RSVP is submitted to the API and success feedback is shown. +3. **Given** a full event, **When** the user taps RSVP, **Then** a waitlist join option is presented instead. +4. **Given** a successful RSVP, **When** the user navigates to the Bookings tab, **Then** the event appears in their bookings list. + +--- + +### User Story 3 - Mobile Authentication (Priority: P1) + +A community member opens the app for the first time and sees a login screen. They can sign in with their existing account (email/password or social login via Entra External ID). After authentication, a JWT is stored securely and used for all API requests. The session persists across app restarts. + +**Why this priority**: Authentication is a prerequisite for all personalized features (RSVP, bookings, profile, notifications). + +**Independent Test**: Open the app. Tap "Sign in". Authenticate with email/password. Verify the home screen shows personalized content. Close and reopen the app — still authenticated. + +**Acceptance Scenarios**: + +1. **Given** the app is not authenticated, **When** it launches, **Then** a login screen is displayed. +2. **Given** the login screen, **When** the user authenticates, **Then** a JWT is obtained and stored in secure storage (iOS Keychain / Android Keystore). +3. **Given** a valid JWT, **When** the user opens the app, **Then** they are automatically authenticated without re-entering credentials. +4. **Given** a JWT that has expired, **When** the user makes an API request, **Then** the JWT is refreshed transparently or the user is prompted to re-authenticate. + +--- + +### User Story 4 - Five-Tab Navigation (Priority: P1) + +A community member uses the bottom tab bar to navigate between five main sections: Home (event feed), Events (search/explore), Teachers (browse profiles), Bookings (my RSVPs), and Profile (settings). Each tab maintains its own navigation stack. Tab switching is instant with no loading delay. + +**Why this priority**: Tab navigation is the mobile app's skeleton — all content hangs off these five tabs. + +**Independent Test**: Launch the app. Tap each of the 5 tabs. Verify each loads its content. Navigate deep into one tab (e.g., Events → Event Detail). Switch tabs. Return — the previous position is preserved. + +**Acceptance Scenarios**: + +1. **Given** the app is authenticated, **When** it renders, **Then** a bottom tab bar shows 5 tabs with icons and labels. +2. **Given** any tab, **When** the user taps another tab, **Then** the switch is instant (no loading spinner). +3. **Given** a deep navigation stack in one tab, **When** the user switches to another tab and back, **Then** the previous navigation position is restored. +4. **Given** iOS, **When** the user swipes back, **Then** the native iOS back gesture works correctly. +5. **Given** Android, **When** the user presses the hardware back button, **Then** navigation pops the current screen or exits the tab. + +--- + +### User Story 5 - Offline Support (Priority: P2) + +A community member is in an area with poor connectivity. Previously loaded events, teacher profiles, and their bookings are available from the local cache. An offline banner is shown at the top of the screen. When connectivity returns, the cache refreshes automatically and the banner disappears. + +**Why this priority**: Mobile users frequently encounter poor connectivity. Offline support is a key differentiator from the web experience. + +**Independent Test**: Load events while online. Enable airplane mode. Navigate the app — cached data is displayed. Disable airplane mode — data refreshes. + +**Acceptance Scenarios**: + +1. **Given** the user has previously loaded data, **When** they go offline, **Then** cached data is displayed from MMKV persistent storage. +2. **Given** the user is offline, **When** any screen loads, **Then** an offline banner is visible (using shared-ui `OfflineBanner` component). +3. **Given** the user regains connectivity, **When** the network status changes, **Then** stale data is refetched and the offline banner disappears. +4. **Given** the user is offline, **When** they attempt a mutation (RSVP), **Then** an error message explains the action requires connectivity. + +--- + +### User Story 6 - Push Notifications (Priority: P3) + +A community member receives push notifications for events they care about — new RSVPs to their events, waitlist promotions, event cancellations. Notification preferences are synced with the server-side preferences from Spec 015. Tapping a notification navigates to the relevant resource. + +**Why this priority**: Push notifications complete the notification architecture (Constitution X) for mobile. Depends on Spec 015 infrastructure. + +**Independent Test**: Enable push notifications. Have someone RSVP to your event. Verify a push notification appears. Tap it — navigates to the event. + +**Acceptance Scenarios**: + +1. **Given** the app is installed, **When** it first launches, **Then** the user is prompted for push notification permission. +2. **Given** push is enabled, **When** a notification event occurs, **Then** a push notification is delivered to the device. +3. **Given** a push notification, **When** the user taps it, **Then** the app opens and navigates to the relevant resource. +4. **Given** notification preferences, **When** the user disables push for a type, **Then** no push notifications are sent for that type. + +--- + +### Edge Cases + +- App must handle JWT refresh race conditions (multiple concurrent requests during refresh) +- Deep links from push notifications must work even when app is cold-started +- MMKV cache must be bounded (max 50MB) with LRU eviction +- Large event lists must use FlatList with windowSize optimization to avoid memory pressure +- Network transitions (WiFi → cellular → offline) must be handled gracefully +- App must handle background → foreground transitions (refresh stale data) + +## Requirements + +### Functional Requirements + +- **FR-001**: Mobile app MUST be built with Expo and React Native, hosted in `apps/mobile/` +- **FR-002**: Mobile app MUST consume the existing REST API — no mobile-specific backend endpoints except JWT auth +- **FR-003**: Authentication MUST use JWT stored in platform-secure storage (Keychain/Keystore) +- **FR-004**: All data fetching MUST use TanStack Query with MMKV offline persistence +- **FR-005**: Navigation MUST use Expo Router with 5-tab bottom navigation +- **FR-006**: Shared-ui components MUST be used via their `.native.tsx` entry points +- **FR-007**: Design tokens MUST be consumed from `@acroyoga/tokens` (Swift/Kotlin values for native, TS values for RN) +- **FR-008**: The app MUST maintain 60fps scrolling on mid-range devices +- **FR-009**: Offline mode MUST display cached data with an offline banner +- **FR-010**: Push notifications MUST integrate with Spec 015 notification preferences + +### Key Entities + +- **Mobile app**: Expo project in `apps/mobile/` +- **Auth module**: JWT-based authentication with secure storage +- **API client**: Typed HTTP client using shared types +- **Offline cache**: MMKV-backed TanStack Query persistence +- **Navigation**: Expo Router 5-tab layout with nested stacks + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: App installs and runs on iOS 16+ simulator and Android 13+ emulator +- **SC-002**: Event list scrolls at 60fps on iPhone 12 / Pixel 6 equivalent +- **SC-003**: Cold start to interactive < 3 seconds +- **SC-004**: Offline mode displays cached events, teachers, and bookings +- **SC-005**: All 17 shared-ui components render correctly on both platforms +- **SC-006**: 5-tab navigation with deep linking functional on both platforms + +## Constitution Compliance + +| Principle | Applicable | Notes | +|-----------|:---:|-------| +| I. API-First | ✅ | Consumes existing REST API. Only new endpoint: `/api/auth/mobile-token` for JWT issuance | +| II. Test-First | ✅ | Unit tests for hooks, API client, auth module. E2E with Detox on iOS/Android | +| III. Privacy | ✅ | JWT in secure storage. No PII cached unencrypted. EXIF stripping on photo uploads | +| IV. Server-Side Authority | ✅ | All business logic on server. Mobile is a thin client | +| V. UX Consistency | ✅ | **Primary** — shared design tokens and components ensure visual consistency across platforms | +| VI. Performance Budget | ✅ | 60fps target. FlatList optimization. Lazy-loaded screens. MMKV for fast reads | +| VII. Simplicity | ✅ | Expo managed workflow. No ejection. Shared components reduce duplication | +| VIII. Internationalisation | ✅ | Reuses Spec 014 translation infrastructure via shared package | +| IX. Scoped Permissions | N/A | Permissions enforced server-side via API | +| X. Notification Architecture | ✅ | Push notifications extend Spec 015 with a new channel | +| XI. Resource Ownership | N/A | Ownership enforced server-side | +| XII. Financial Integrity | ✅ | Payments handled via in-app browser to web payment flow | +| XIII. Development Environment | ✅ | Expo Go for development. EAS Build for CI | +| XIV. Managed Identity | N/A | Mobile does not access Azure services directly | diff --git a/specs/016-mobile-app/tasks.md b/specs/016-mobile-app/tasks.md new file mode 100644 index 0000000..449fc25 --- /dev/null +++ b/specs/016-mobile-app/tasks.md @@ -0,0 +1,225 @@ +# Tasks: Mobile App (Expo/React Native) + +**Input**: Design documents from `/specs/016-mobile-app/`, Spec 008 Phase 6 deferred tasks (T051–T079) +**Prerequisites**: plan.md (required), spec.md (required) + +**Tests**: Constitution mandates test-first development. Tests are included and MUST fail before implementation. + +**Organization**: Tasks are grouped by phase. This is the largest spec — 10 phases with 63 tasks. Many tasks map directly to Spec 008's deferred Phase 6 tasks (T051–T079). + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Mobile app**: `apps/mobile/` +- **Mobile routes**: `apps/mobile/app/` +- **Mobile lib**: `apps/mobile/lib/` +- **Mobile tests**: `apps/mobile/__tests__/` +- **Shared hooks**: `packages/shared/src/hooks/` +- **Shared types**: `packages/shared/src/types/` +- **Web API routes**: `apps/web/src/app/api/` +- **Web integration tests**: `apps/web/tests/integration/` + +--- + +## Phase 1: Scaffolding & Configuration (Blocking Prerequisites) + +**Purpose**: Create Expo project, configure monorepo integration, TypeScript, EAS Build + +*Maps to Spec 008 T051–T055* + +- [ ] T001 [US4] Scaffold Expo app in `apps/mobile/` using `npx create-expo-app`. Configure `package.json` with workspace name `@acroyoga/mobile` and dependencies on `@acroyoga/shared`, `@acroyoga/shared-ui`, `@acroyoga/tokens` +- [ ] T002 [P] [US4] Configure `apps/mobile/metro.config.js` for monorepo — resolve `packages/shared`, `packages/shared-ui`, `packages/tokens` via `watchFolders` and `nodeModulesPaths` +- [ ] T003 [P] [US4] Configure `apps/mobile/tsconfig.json` extending `../../tsconfig.base.json` with React Native path aliases and JSX settings +- [ ] T004 [P] [US4] Configure `apps/mobile/app.json` — app name, slug, scheme (deep links), iOS bundleIdentifier, Android package, icons, splash screen +- [ ] T005 [P] [US4] Configure `apps/mobile/eas.json` — development, preview, and production build profiles for iOS and Android + +**Checkpoint**: Expo app scaffolded, Metro resolves monorepo packages, TypeScript compiles, `npx expo start` launches without errors. + +--- + +## Phase 2: Authentication (US3 — Mobile Auth) + +**Purpose**: Implement JWT auth, SecureStore, API client, server-side token endpoint + +### Tests for Phase 2 + +- [ ] T006 [P] [US3] Unit tests for auth module — JWT storage, retrieval, refresh, expiry check in `apps/mobile/__tests__/auth.test.ts` +- [ ] T007 [P] [US3] Unit tests for API client — auth header injection, 401 handling, token refresh retry in `apps/mobile/__tests__/api-client.test.ts` +- [ ] T008 [P] [US3] Integration test for mobile-token endpoint — session validation, JWT issuance, 401 for unauthenticated in `apps/web/tests/integration/auth/mobile-token.test.ts` + +### Implementation for Phase 2 + +- [ ] T009 [US3] Create JWT auth module in `apps/mobile/lib/auth.ts` — `signIn()`, `signOut()`, `getToken()`, `refreshToken()`, `isAuthenticated()` using `expo-secure-store` for JWT storage +- [ ] T010 [US3] Create typed API client in `apps/mobile/lib/api-client.ts` — base URL configuration, automatic JWT header injection, 401 interception with token refresh, typed request/response using `@acroyoga/shared` types +- [ ] T011 [US3] Create `POST /api/auth/mobile-token` API route in `apps/web/src/app/api/auth/mobile-token/route.ts` — validates existing session, issues JWT with user claims, supports refresh tokens. Protected by `requireAuth()` +- [ ] T012 [US3] Add `MobileTokenResponse` type to `packages/shared/src/types/auth.ts` — `{ token: string, refreshToken: string, expiresAt: string }` + +**Checkpoint**: Authentication functional — user can sign in, JWT stored securely, API client sends authenticated requests. + +--- + +## Phase 3: Navigation & Layout (US4 — Five-Tab Navigation) + +**Purpose**: Set up Expo Router with 5-tab bottom navigation, nested stacks, platform transitions + +### Tests for Phase 3 + +- [ ] T013 [P] [US4] Unit tests for navigation configuration — tab routes, stack screens, auth gate redirect in `apps/mobile/__tests__/navigation.test.ts` + +### Implementation for Phase 3 + +- [ ] T014 [US4] Create root layout `apps/mobile/app/_layout.tsx` — auth gate (redirect to login if not authenticated), global providers (TanStack Query, theme) +- [ ] T015 [US4] Create auth group layout `apps/mobile/app/(auth)/_layout.tsx` and login screen `apps/mobile/app/(auth)/login.tsx` +- [ ] T016 [US4] Create tab layout `apps/mobile/app/(tabs)/_layout.tsx` — 5 tabs (Home, Events, Teachers, Bookings, Profile) with icons from `@expo/vector-icons`, platform-specific tab bar styling +- [ ] T017 [P] [US4] Create Events tab stack `apps/mobile/app/(tabs)/events/_layout.tsx` — stack navigator with native transitions (push on iOS, material on Android) +- [ ] T018 [P] [US4] Create Teachers tab stack `apps/mobile/app/(tabs)/teachers/_layout.tsx` +- [ ] T019 [P] [US4] Create Profile tab stack with settings nesting `apps/mobile/app/(tabs)/profile/` +- [ ] T020 [US4] Create not-found screen `apps/mobile/app/+not-found.tsx` + +**Checkpoint**: 5-tab navigation functional. Auth gate redirects unauthenticated users. Platform-appropriate transitions. + +--- + +## Phase 4: Core Screens — Events (US1, US2) + +**Purpose**: Build Home feed, Events list/detail, RSVP flow + +### Tests for Phase 4 + +- [ ] T021 [P] [US1] Unit tests for shared `useEvents` hook — query key generation, pagination, filter params in `packages/shared/src/hooks/__tests__/useEvents.test.ts` +- [ ] T022 [P] [US1] Unit tests for Home screen rendering — event list, loading state, empty state in `apps/mobile/__tests__/screens/home.test.tsx` +- [ ] T023 [P] [US2] Unit tests for RSVP flow — role selection, submission, error handling in `apps/mobile/__tests__/screens/rsvp.test.tsx` + +### Implementation for Phase 4 + +- [ ] T024 [US1] Create shared `useEvents` hook in `packages/shared/src/hooks/useEvents.ts` — TanStack Query hook wrapping `GET /api/events` with filter params, pagination, and cache key +- [ ] T025 [US1] Create Home tab screen `apps/mobile/app/(tabs)/index.tsx` — FlatList of upcoming events using shared `EventCard` component (`.native.tsx`), pull-to-refresh, loading/empty states +- [ ] T026 [US1] Create Events list screen `apps/mobile/app/(tabs)/events/index.tsx` — searchable, filterable event list with category filter chips +- [ ] T027 [US1] Create Event detail screen `apps/mobile/app/(tabs)/events/[id].tsx` — full event details, map preview, attendee count, RSVP button +- [ ] T028 [US2] Create RSVP action in Event detail — role selection bottom sheet, API submission, success/error feedback, navigate to bookings on success + +**Checkpoint**: Event browsing and RSVP functional. Shared-ui EventCard renders on native. + +--- + +## Phase 5: Remaining Screens (US1, US4) + +**Purpose**: Build Teachers, Bookings, Profile screens + +### Tests for Phase 5 + +- [ ] T029 [P] [US1] Unit tests for shared `useTeachers` hook in `packages/shared/src/hooks/__tests__/useTeachers.test.ts` + +### Implementation for Phase 5 + +- [ ] T030 [US1] Create shared `useTeachers` hook in `packages/shared/src/hooks/useTeachers.ts` +- [ ] T031 [US1] Create Teachers list screen `apps/mobile/app/(tabs)/teachers/index.tsx` — FlatList with search, certification badges +- [ ] T032 [US1] Create Teacher detail screen `apps/mobile/app/(tabs)/teachers/[id].tsx` — profile, certifications, reviews, upcoming events +- [ ] T033 [US4] Create Bookings screen `apps/mobile/app/(tabs)/bookings/index.tsx` — list of user's RSVPs grouped by upcoming/past +- [ ] T034 [US4] Create Profile screen `apps/mobile/app/(tabs)/profile/index.tsx` — user info, social links, edit profile action +- [ ] T035 [P] [US4] Create Notification settings screen `apps/mobile/app/(tabs)/profile/settings/notifications.tsx` — reuse preference types from Spec 015 + +**Checkpoint**: All 5 tabs have content. Core mobile experience is complete. + +--- + +## Phase 6: Offline Support (US5) + +**Purpose**: MMKV persistence, connectivity monitoring, offline banner + +### Tests for Phase 6 + +- [ ] T036 [P] [US5] Unit tests for offline module — MMKV serialization, cache size management, LRU eviction in `apps/mobile/__tests__/offline.test.ts` +- [ ] T037 [P] [US5] Unit tests for connectivity hook — online/offline state transitions, event listeners in `apps/mobile/__tests__/connectivity.test.ts` + +### Implementation for Phase 6 + +- [ ] T038 [US5] Create offline module in `apps/mobile/lib/offline.ts` — TanStack Query persister using `react-native-mmkv`, cache size limit (50MB), LRU eviction strategy +- [ ] T039 [US5] Create connectivity module in `apps/mobile/lib/connectivity.ts` — `useOnlineStatus()` hook using `@react-native-community/netinfo`, online/offline event handling +- [ ] T040 [US5] Integrate offline persistence with TanStack Query in root layout — `PersistQueryClientProvider` with MMKV persister +- [ ] T041 [US5] Add `OfflineBanner` (shared-ui `.native.tsx`) to root layout — visible when offline, auto-hides when online + +**Checkpoint**: Cached data available offline. Connectivity transitions handled gracefully. + +--- + +## Phase 7: Push Notifications (US6) + +**Purpose**: expo-notifications integration, device token registration, deep link handling + +*Depends on Spec 015 (Background Jobs & Notifications)* + +### Tests for Phase 7 + +- [ ] T042 [P] [US6] Unit tests for push notification setup — permission request, token registration, notification tap handler in `apps/mobile/__tests__/push.test.ts` + +### Implementation for Phase 7 + +- [ ] T043 [US6] Configure `expo-notifications` in `apps/mobile/app.json` — iOS APNs, Android FCM credentials +- [ ] T044 [US6] Create push notification module in `apps/mobile/lib/push.ts` — request permission, register device token with server, handle foreground/background notifications +- [ ] T045 [US6] Create `POST /api/notifications/devices` API route in `apps/web/src/app/api/notifications/devices/route.ts` — register device token for push delivery +- [ ] T046 [US6] Implement notification tap deep linking — parse notification data, navigate to relevant screen using Expo Router + +**Checkpoint**: Push notifications delivered to device. Tapping navigates to relevant content. + +--- + +## Phase 8: Testing & CI (All Stories) + +**Purpose**: Comprehensive testing, CI integration with EAS Build + +- [ ] T047 [P] Verify all shared-ui components render correctly on iOS simulator — snapshot test each of the 17 components in `apps/mobile/__tests__/shared-ui/` +- [ ] T048 [P] Verify all shared-ui components render correctly on Android emulator +- [ ] T049 Create Detox E2E test for login → browse events → RSVP flow on iOS in `apps/mobile/e2e/` +- [ ] T050 [P] Create Detox E2E test for same flow on Android +- [ ] T051 Add mobile CI step to `.github/workflows/ci.yml` — install, typecheck, unit test (no device-dependent E2E in CI) +- [ ] T052 Configure EAS Build in CI — preview builds on PR, production builds on main merge + +**Checkpoint**: All tests pass. CI builds mobile app. + +--- + +## Phase 9: Platform-Specific Optimization (US1, US4) + +**Purpose**: iOS and Android specific polish + +- [ ] T053 [US4] iOS: Verify native back swipe gesture works correctly in all stack navigators +- [ ] T054 [P] [US4] iOS: Add haptic feedback to RSVP confirmation and tab switches +- [ ] T055 [P] [US4] iOS: Support Dynamic Type (system font size) for accessibility +- [ ] T056 [US4] Android: Verify hardware back button navigation in all screens +- [ ] T057 [P] [US4] Android: Configure material transitions (shared element transitions for event detail) +- [ ] T058 [P] [US4] Android: Configure edge-to-edge display with proper status bar handling + +--- + +## Phase 10: Polish & Documentation + +- [ ] T059 Update `README.md` — add Spec 016 to specs table, add mobile workspace to project structure +- [ ] T060 Update deferred mobile tasks in Spec 008 (T051–T079) — change status to "addressed by Spec 016" +- [ ] T061 Close GitHub issues #370–#375 (deferred mobile tasks) with reference to Spec 016 +- [ ] T062 Add mobile development section to `CONTRIBUTING.md` — Expo development workflow, simulator setup, EAS Build +- [ ] T063 Run full validation checklist for web workspace (ensure no regressions) + +**Checkpoint**: Documentation updated. All deferred mobile tasks resolved. Web validation passes. + +--- + +## Dependencies & Execution Order + +- **Phase 1**: No dependencies — can start immediately +- **Phase 2**: Depends on Phase 1 (needs scaffolded app) +- **Phase 3**: Depends on Phase 1 (needs Expo Router setup) +- **Phase 4**: Depends on Phases 2–3 (needs auth and navigation) +- **Phase 5**: Depends on Phases 3–4 (needs navigation and shared hooks) +- **Phase 6**: Depends on Phase 4 (needs TanStack Query setup) +- **Phase 7**: Depends on Phase 2 and Spec 015 (needs auth and notification infrastructure) +- **Phase 8**: Depends on Phases 4–7 (needs all screens built) +- **Phase 9**: Depends on Phase 8 (needs functional app to polish) +- **Phase 10**: Depends on all prior phases + +**External Dependency**: Phase 7 (Push Notifications) depends on Spec 015 (Background Jobs & Notifications) being implemented.