From 9f7209f44d9c83f090baf497dd0130a33348f72b Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 14:17:59 +1100 Subject: [PATCH] feat(invitations): implement invitation accept flow with TDD --- frontend-spec.md | 5 +++-- src/api/client.ts | 15 +++++++++++++++ src/api/invitations.ts | 18 ++++++++++++++++++ src/views/InvitationAccept.vue | 33 +++++++++++++++++++++++++++++++-- tests/invitations.api.test.ts | 31 +++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 src/api/invitations.ts create mode 100644 tests/invitations.api.test.ts diff --git a/frontend-spec.md b/frontend-spec.md index 98acaab..fa98433 100644 --- a/frontend-spec.md +++ b/frontend-spec.md @@ -85,7 +85,7 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th - Build the `Welcome.vue` view for creating the first household. - Build the `CreateAccount.vue` view. - Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success. - - Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI is implemented with email/displayName/password form calling `useAuth.createAccount`; Invitation Accept remains TODO. + - Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls `POST /api/v1/invitations/accept`, and redirects to the accepted household. Uses a temporary raw fetch helper until OpenAPI adds this endpoint. 4. **[~] Integrate Household Context into the App**: - **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client. @@ -162,7 +162,8 @@ Progress Log (Nov 1, 2025) - Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. - Router guard updated to handle public/multitenant routing and redirects. - Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation. -- Next: Implement Invitation Accept flow and Household Settings (invite members form), then remove legacy Person UI. +- Implemented Invitation Accept flow: added `src/api/invitations.ts` with `acceptInvitation` (temporary raw fetch), `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`. +- Next: Implement Household Settings (invite members form), then remove legacy Person UI. --- diff --git a/src/api/client.ts b/src/api/client.ts index b4f6c6d..c85f490 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -35,3 +35,18 @@ export const api = createClient({ }) }, }) + +// For endpoints not yet in OpenAPI, provide a raw fetch that preserves header injection and base URL behavior +export async function fetchApi(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers || {}) + const slug = householdSlugProvider ? householdSlugProvider() : null + if (slug) headers.set('X-Household-Slug', slug) + const token = authTokenProvider ? authTokenProvider() : null + if (token) headers.set('Authorization', `Bearer ${token}`) + const url = baseUrl ? new URL(path, baseUrl).toString() : path + return globalThis.fetch(url, { + credentials: 'include', + ...init, + headers, + }) +} diff --git a/src/api/invitations.ts b/src/api/invitations.ts new file mode 100644 index 0000000..6891315 --- /dev/null +++ b/src/api/invitations.ts @@ -0,0 +1,18 @@ +import { fetchApi } from '@/api/client' + +export type Household = { id: number; name: string; slug: string } + +export async function acceptInvitation(token: string): Promise { + const resp = await fetchApi('/api/v1/invitations/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`) + const data = await resp.json().catch(() => null) + const h = data?.household + if (!h || typeof h.id !== 'number' || typeof h.slug !== 'string' || typeof h.name !== 'string') { + throw new Error('Invalid invitation accept response') + } + return { id: h.id, name: h.name, slug: h.slug } +} diff --git a/src/views/InvitationAccept.vue b/src/views/InvitationAccept.vue index 67f0d6e..45eba25 100644 --- a/src/views/InvitationAccept.vue +++ b/src/views/InvitationAccept.vue @@ -1,10 +1,39 @@ + + diff --git a/tests/invitations.api.test.ts b/tests/invitations.api.test.ts new file mode 100644 index 0000000..1cb245e --- /dev/null +++ b/tests/invitations.api.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { server, http, HttpResponse } from './test-setup' +import { loginWithPassword } from '@/api/auth' +import { acceptInvitation } from '@/api/invitations' + +describe('invitations api', () => { + it('acceptInvitation posts token and returns household slug', async () => { + // simulate login so Authorization header is present + server.use( + http.post('*/api/v1/auth/login', () => + HttpResponse.json({ accessToken: 'tok123', tokenType: 'bearer', user: { id: 9, email: 'u@e', displayName: 'U' } }) + ) + ) + await loginWithPassword('u@e', 'pw') + + server.use( + http.post('*/api/v1/invitations/accept', async ({ request }) => { + const body = await request.json() + expect(body).toEqual({ token: 'abc' }) + const auth = request.headers.get('authorization') + expect(auth?.toLowerCase()).toBe('bearer tok123') + return HttpResponse.json({ household: { id: 1, name: 'Smiths', slug: 'the-smiths' } }) + }) + ) + + const result = await acceptInvitation('abc') + expect(result.slug).toBe('the-smiths') + expect(result.name).toBe('Smiths') + expect(result.id).toBe(1) + }) +})