Compare commits

..

No commits in common. "e873bbc3f86731a53c0046f6d3218728f97ab2a2" and "d69128a334fe2916a0421e9c2837eff9f65fc577" have entirely different histories.

21 changed files with 392 additions and 329 deletions

View file

@ -1,30 +0,0 @@
---
description: 'UAT With Devtools'
tools: ['edit', 'search', 'runCommands', 'chromedevtools/chrome-devtools-mcp/*', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'fetch', 'todos', 'runTests']
---
You are a UAT (User Acceptance Testing) assistant with access to a full browser and developer tools. Your role is to help users test software applications in browser by simulating real-world usage scenarios and identifying any issues or bugs. You inspect DOM, inspect screenshots closely, check margins, ensure components are well aligned, and understand UX and design principles well. The browser is your primary tool.
You have expert code reading and writing experience. You perform root cause analysis, produce eloquent code with strict typing, and always follow TDD principles. You have many additional skills and tools at your disposal to help you perform your role fully.
This is a meal planning and recipe management web application built with Vue 3 and TypeScript.
Features:
- User authentication (sign up, log in, log out)
- Household segregation and management (no shared meals, recipes, or shopping lists between households)
- Natural language ingredient parsing
- Recipe book with search and filtering
- Meal planning calendar
- Shopping list generation
- Adhoc extra items for meals and shopping lists
See uat-profiles.md for environment URLs and test user account details. Update as you create new profiles and personas.
Prefer navigation via UI and router links over direct URL manipulation.
Note improvements as you go, especially if they impact usability or accessibility.
Note navigational oddities or broken flows.
The team cannot repeat your testing. They can not resolve issues without detailed reports. On crash or errors, capture console logs, stack traces, and always search for and include useful network logs (eg on save error, include the save request, response and prior get requests that feed into it). Note whether to defer to backend or frontend teams.
Strive to use authentic data in testing, reflecting real-world usage. Funny is even better.

View file

@ -48,15 +48,13 @@ Key axioms
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt. - No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt.
- Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`). - Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`).
- Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary. - Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary.
- Let the type system prevent mistakes: encode intent in function signatures. Prefer explicit option objects to loose primitives, make required fields non-optional, and avoid permissive overloads that widen the error surface.
- Navigation is slug-scoped and helper-driven: only use `src/router/links.ts` helpers and named routes. For non-id pages pass `{ slug }` or omit to infer; for id pages pass `id` or `{ id, slug }`. Never build path strings or read slugs from route params in components.
Layout Layout
- `src/api/` — Typed client and SDK boundary - `src/api/` — Typed client and SDK boundary
- `src/domain/` — Domain types and decoders - `src/domain/` — Domain types and decoders
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert) - `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
- `src/components/` — UI components and pages - `src/components/` — UI components and pages
- `src/router/` — Routes and helpers (`links.ts` for slug-scoped navigation, plus `parseRouteId`, `parseQueryString`) - `src/router/` — Routes and helpers (`parseRouteId`, `parseQueryString`)
Testing and tooling Testing and tooling
- Vitest + MSW under `tests/` - Vitest + MSW under `tests/`

View file

@ -1,36 +1,305 @@
# Frontend Specification: Final Polish ## 0. Current State (Nov 2, 2025)
## 1. Current State & Objective All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green. Adhoc shopping items (add/remove) are implemented against the new scoped endpoints.
**The multi-tenancy migration is functionally complete and successful.** The frontend has been refactored to a robust, household-scoped application using JWT-based authentication. All legacy `.js` tests have been removed, and the codebase is clean. Status of tests and typing
- All tests pass: 18 files, 27 tests (legacy JS tests removed; slug-only routes; memory history fallback; unauthenticated and refresh-401 guard redirects covered).
- `tsc` and `vue-tsc` pass with no errors.
The objective is to complete the final remaining UI feature to officially close out the project. Prerequisites
- Node.js 20.19.0 LTS or newer (chrome-devtools-mcp requires >=20.19.0). An `.nvmrc` file is provided; run `nvm use` to switch.
## 2. Final Tasks ## Test credentials for manual QA (Nov 2, 2025)
This checklist represents all remaining work. - Email: specuser+20251102@example.com
- Display Name: Spec User
- Password: SpecPassw0rd!
- Household slug: faulconfridge-qa2
- [x] **1. Implement "Copy Invite Link" UI**: Notes
- **Objective**: Implement the user interface for inviting new members to a household using a "copy link" feature. - Created via live browser flow; redirected to slug-scoped app shell.
- **File**: `src/views/HouseholdSettings.vue` - Verified Meal Plan and Shopping pages load under `/:householdSlug` without missing-param errors.
- **Action**:
1. ~~The backend team will provide a new endpoint that, when called, returns a JSON object with an `invite_link`.~~ ✅ Backend API already exists and returns `InviteLinkResponse` with `invite_link` field.
2. ~~Update the "Invite" button logic to call this new endpoint.~~ ✅ Created `createInviteLink()` function in `src/api/invitations.ts`.
3. ~~On a successful response, use the browser's Clipboard API (`navigator.clipboard.writeText(response.invite_link)`) to copy the link.~~ ✅ Implemented in `onCopyInviteLink()` handler.
4. ~~Display a confirmation toast to the user (e.g., "Invite link copied to clipboard!").~~ ✅ Using `useAlert()` composable to show success toast.
- **Status**: ✅ **COMPLETED** - Added "Copy Invite Link" button to HouseholdSettings.vue with full clipboard integration and toast notification.
- [x] **2. Final Codebase Sweep**: # Frontend Specification: Household Multi-Tenancy (v2)
- **Objective**: Perform a final search for and remove any dead code, comments, or variables related to the old system.
- **Action**: Search the entire codebase for the following keywords: `legacy`, `old`, `previous`, `workaround`, `fallback`, `person`.
- **Outcome**: ✅ **COMPLETED** - Removed all legacy comments from:
- `src/composables/useAuth.ts` - Removed "Legacy username login removed" comment
- `src/components/LoginPage.vue` - Removed "Legacy quick-login removed" and "legacy login removed" comments
- `src/api/auth.ts` - Removed "Legacy username login has been removed" comment
- `src/api/sdk.ts` - Removed legacy shopping list stub functions (`getMyShoppingList`, `saveMyShoppingList`)
- `src/composables/useShopping.ts` - Removed references to removed stub functions
- **Note**: Remaining uses of "fallback", "person", etc. are legitimate application logic, not legacy code.
- [x] **3. Mark Project as Complete**: ## 1. Objective
- **Objective**: Once the above tasks are done, this document is complete.
- **Action**: ✅ **PROJECT COMPLETE** - All migration tasks successfully completed on November 2, 2025. Transition the frontend from a single-tenant application to a multi-tenant one centered around the concept of "Households". This involves refactoring the existing authentication system, user onboarding, and data presentation to ensure all information (recipes, meals, shopping lists) is scoped to the active household.
This plan is adapted to the existing codebase, focusing on refactoring rather than starting from scratch.
## 2. Core Concepts
- **Household**: A private group of users. All data is isolated and only visible to members of that household.
- **URL-based Tenancy**: The active household is determined by the URL, e.g., `/the-smiths/recipes`. This makes household context explicit and shareable.
- **Household Switching**: Users belonging to multiple households can switch between them.
- **Authentication**: Evolve the existing system from a simple username login to a robust one supporting email/password and Google OAuth, using JWTs.
## 3. User Flows & UI Requirements
### 3.1. Authentication & Onboarding
- **Login Page (`/login`)**:
- **Refactor `src/components/LoginPage.vue`**.
- Replace the current username input with:
- Email & Password login fields.
- A "Sign in with Google" button.
- Add a link to the "Create Account" page (`/create-account`).
- **Create Account Page (`/create-account`)**:
- **Create a new view `src/views/CreateAccount.vue`**.
- UI components for:
- Email, Display Name, and Password input.
- "Sign up with Google" button.
- A link back to the "Login" page.
- **Post-Login Household Selection (`/welcome`)**:
- **Create a new view `src/views/Welcome.vue`**.
- After a new user logs in for the first time, they are directed here.
- The page should present two choices:
1. **Create a new household**: A simple form with "Household Name".
2. **Join an existing household**: This section should clearly state: "To join a household, ask an existing member to send an invitation to your email address."
- **Invitation Flow**:
- A user receives an email with a link like `https://<app-domain>/invitations/accept?token=...`.
- Visiting this link while logged in should add them to the household and redirect them to that household's dashboard.
### 3.2. In-App Experience
- **Household-Scoped URLs**:
- All application routes must be nested under a household slug: `/:householdSlug/recipes`, `/:householdSlug/shopping`, etc.
- The router must be updated to handle this dynamic parameter. The `householdSlug` will be used in all API calls to fetch household-specific data.
- **Household Switcher**:
- **Create a new component `src/components/HouseholdSwitcher.vue`**.
- Place it in a prominent location (e.g., inside `App.vue`'s navigation bar).
- It should list all households the current user is a member of.
- Clicking a household name should navigate the user to the dashboard of that household, e.g., `/<new-household-slug>/dashboard`.
- **Invite Members UI**:
- **Create a new view `src/views/HouseholdSettings.vue`**.
- It should be accessible at `/:householdSlug/settings/members`.
- It should contain a simple form to enter an email address and a button to "Send Invitation".
- It should also list current household members.
## 4. Actionable Implementation Steps
1. **[x] Refactor Authentication State & API**:
- **Modify `src/api/auth.ts`**:
- Implemented JWT-based login/register using new endpoints; Authorization header injected via client provider.
- `loginWithPassword(email, password)` and `createAccount(email, displayName, password)` now return `User` and set token.
- `logout()` clears token and cached user.
- `currentUser()` now performs token-only refresh (no user in response) and then loads user context via `/api/v1/users/me/households`.
- **Modify `src/composables/useAuth.ts`**:
- Updated to use `User`, added `loginWithPassword` + `logout`, and state for `households` + `activeHousehold`.
- Added `fetchHouseholds()` which hits `/api/v1/users/me/households` and stores state.
2. **[x] Update Router for Multi-Tenancy**:
- **Modify `src/router/index.ts`**:
- Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`.
- Removed legacy flag and flat routes: feature routes are always nested under `/:householdSlug/...`.
- **Refactor the `beforeEach` guard**:
- Allows public routes.
- After auth, fetches households via `useAuth().fetchHouseholds()`.
- If none: redirect to `/welcome`.
- If at root (`/`): redirect to first household's `/:householdSlug/mealplan`.
- Ensures `activeHousehold` is set when navigating within a household.
- **Nest existing routes**: Always under `/:householdSlug` (legacy flat routes removed).
- **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests assert slug-only mode.
3. **[~] Implement Onboarding and Invitation Flows**:
- 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 implemented. Invitation Accept implemented: reads `token` from query, calls typed `POST /api/v1/invitations/accept`, and redirects to the accepted household.
4. **[~] Integrate Household Context into the App**:
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
- **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`.
- **Update API Services**: No household header injection; Authorization header provided by token provider. Cookies are only sent for the refresh endpoint; all other requests avoid credentials. SDK/services use typed path params.
- Verified by `tests/household.header.test.ts`, auth tests, and router guard tests.
5. **[~] Implement Invitation UI**:
- Build the `HouseholdSettings.vue` view for inviting members and listing current members.
- Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing implemented using typed endpoint `GET /api/v1/households/{householdSlug}/members`.
6. **[ ] Final Review & Cleanup**:
- Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity.
- Ensure all data displays are correctly filtered by the active household by verifying the `householdSlug` is passed in all API calls.
- Test all user flows: new user signup, login, creating a household, joining via invitation, and switching between households.
---
## 0. Current State (Nov 2, 2025)
All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green. Adhoc shopping items (add/remove) are implemented.
What exists now
- Auth
- `src/api/auth.ts`: email/password login and register; token-only refresh in `currentUser()` which then loads `/api/v1/users/me/households`.
- `src/composables/useAuth.ts`: manages `user`, `households`, and `activeHousehold`; exposes login/logout/createAccount and `fetchHouseholds()`.
- Routing
- `src/router/index.ts`: slug-only nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`.
- Guard fetches households, redirects root `/` to the first household's `mealplan`, and uses memory history in tests (hash in browser).
- SDK/API
- `src/api/sdk.ts`: recipes, meals, and shopping are migrated to `/api/v1/households/{householdSlug}/...` typed endpoints. Person endpoints removed. Parse endpoints are typed:
- `POST /api/v1/households/{householdSlug}/recipes/parse-from-url` returns `RecipeCreate-Output`; SDK constructs a minimal `RecipeOut` for decoding (id/createdById set to -1).
- `GET /api/v1/households/{householdSlug}/ingredients/parse?lines=...` returns `Ingredient[]`.
- Shopping adhoc requests: `POST /api/v1/households/{householdSlug}/shopping/current/ingredients` (requestIngredient) and `DELETE /api/v1/households/{householdSlug}/shopping/current/ingredients` (unrequestIngredient) are implemented.
- `src/api/client.ts`: Authorization header provider only; household header injection removed.
- Domain & UI
- Member arrays (`chefs`, `consumers`, `cleanup`) normalized to `MemberRef` `{ id, displayName }` with decoders.
- `MemberList.vue` replaces `PersonList.vue`.
- Invitation Accept flow implemented; Household Settings supports sending invitations and listing members using typed endpoints.
- MyShopping: uses the new adhoc ingredient endpoints to add/remove personal items. UI includes an "Add existing ingredient" selector (requests by ingredientId) and supports removal; in-place line edits are local-only for now. Legacy v1 stubs are unused and will be removed in a follow-up.
Status of tests and typing
- All tests pass: 18 files, 27 tests (legacy JS tests purged).
- `tsc` and `vue-tsc` pass with no errors.
---
## Amendment: API Services and Typing Strategy (Updated Nov 1, 2025)
The backend OpenAPI has been updated and now exposes `householdSlug` as a path parameter for scoped endpoints (recipes, meals, shopping, invitations, whoami). Auth endpoints are fully typed (login/register/refresh/logout). Invitation acceptance remains a global endpoint (`/api/v1/invitations/accept`) with a typed operation.
Implications and actions (completed):
- Removed `X-Household-Slug` and migrated to typed path parameters across recipes, meals, and shopping.
- `currentUser()` updated to token-only refresh and household loading.
- Invitations: sending is typed under household scope; accept is typed globally.
- Members listing is now typed under `GET /api/v1/households/{householdSlug}/members`; UI updated to display `displayName` and `role`.
---
Progress Log (Nov 2, 2025)
- Implemented adhoc shopping items (add/remove): SDK and composable expose `requestIngredient` and `unrequestIngredient`; `MyShoppingPage.vue` wired with a selector and delete flow; removed `parseProduct` usage.
- Purged legacy .test.js files; retained a lean TS test suite.
- Verified all checks green.
Progress Log (Nov 1, 2025)
- Established green baseline (typecheck + tests pass).
- Added auth API tests driving a minimal multitenant-ready surface.
- Implemented `loginWithPassword`, `logout`, and stubs in `src/api/auth.ts` to satisfy tests.
- Refactored `useAuth` to add households and activeHousehold state, plus `loginWithPassword` and `logout`. Tests added and passing.
- Added router tests and implemented slug-only nested routes and new public routes. Placeholders for onboarding/invitations added.
- Implemented `useHousehold.ts`, Authorization provider in API client, minimal `HouseholdSwitcher.vue`, and mounted it. Replaced header injection test with path-scoped assertion.
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. `currentUser` refactored to token-only refresh plus households load.
- 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.
- Implemented Invitation Accept flow: added `src/api/invitations.ts` with typed `acceptInvitation`, `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.
- Added a Settings link to `HouseholdSwitcher.vue` to surface the `household-settings` route for easier discovery.
- Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via the typed endpoint.
- Router uses memory history in tests to avoid relying on `window.location`. Added unauthenticated and refresh-401 guard redirect tests.
- Backend updated OpenAPI and codegen has been run:
- Many endpoints are now path-scoped with `{householdSlug}` (recipes, meals, shopping, invitations (create), whoami).
- Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`.
- Completed migration to typed path parameters; header injection removed; only Google OAuth remains using raw fetch until typed endpoints are available.
Refinements (Nov 1, 2025, later):
- Lint hardening: removed remaining `as` and unsafe assertions across SDK/decoders.
- decodeRecipe/decodeMeal simplified to use concrete OpenAPI shapes and defaults; legacy Person normalization removed.
- Parse flows are fully typed; SDK constructs a minimal RecipeOut for decoding where needed (no ad-hoc property guards).
- UI polish: MemberList and EditMealPage CSS class names unified (person-* → member-*). Login page shows a Google sign-in button wired to the placeholder handler.
Google OAuth (Nov 1, 2025, later):
- Added tests for Google OAuth start and callback.
- Implemented `handleGoogleLogin()` to retrieve an OAuth start URL and navigate.
- Implemented `completeGoogleLogin(code, state?)` using raw fetch to exchange the code and set the token/user; verified Authorization header on subsequent calls.
- Current UI: Login page button uses the returned URL to redirect. Callback route/UI still pending wiring (next).
---
## Detailed Tasks by File/Module
New files
- `src/views/CreateAccount.vue`: Email, Display Name, Password; Google signup; link to login.
- `src/views/Welcome.vue`: Create first household or instructions to join via invite.
- `src/views/HouseholdSettings.vue`: Invite members by email; list members.
- `src/components/HouseholdSwitcher.vue`: Lists user households and navigates to selected household dashboard.
- `src/composables/useHousehold.ts`: Exposes `activeHouseholdSlug` from route and small helpers.
Auth
- `src/api/auth.ts`:
- Replace `login(username: string)``login(email: string, password: string)`.
- Add `createAccount`, `handleGoogleLogin`, `logout`; update `currentUser` to refresh JWT/session.
- Store token per backend guidance; clear on `logout`.
- `src/composables/useAuth.ts`:
- Manage `user`, `households`, `activeHousehold` state; expose `login`, `register`, `handleGoogleLogin`, `logout`, `loadUser`, `setActiveHousehold`.
Router
- `src/router/index.ts`:
- Add public routes: `/create-account`, `/welcome`, `/invitations/accept`.
- Nest feature routes under `/:householdSlug`.
- Guard: after auth, fetch households; redirect root `/` to first household dashboard; route `/welcome` if none.
- Update imperative navigations to include `{ householdSlug }` via named routes.
API client
- `src/api/client.ts`:
- Uses typed `{ params: { path: { householdSlug } } }` across SDK; `X-Household-Slug` header provider removed.
- Authorization provider remains as-is, fed by JWT token from login/refresh.
SDK
- `src/api/sdk.ts`:
- Recipes, meals, shopping fully path-scoped to households.
- Newly typed endpoints (post-latest codegen):
- `GET /api/v1/households/{householdSlug}/shopping/current` — current aggregated list.
- `GET /api/v1/households/{householdSlug}/shopping/{list_id}` — purchased list by id.
- `POST /api/v1/households/{householdSlug}/shopping` — purchase items (storeName + items).
- `POST /api/v1/households/{householdSlug}/shopping/current/meals/me` — request a meal.
- `DELETE /api/v1/households/{householdSlug}/shopping/current/meals/{meal_id}` — unrequest a meal.
- `POST /api/v1/households/{householdSlug}/shopping/current/ingredients` — request an ingredient (by id) for shopping.
- `DELETE /api/v1/households/{householdSlug}/shopping/current/ingredients` — unrequest an ingredient (by id) for the current user; idempotent.
- Google OAuth endpoints remain raw until they are added to the OpenAPI.
UI
- Login Page: Refactored to show email/password form; Google sign-in planned. Link to Create Account added.
- Add `HouseholdSwitcher.vue` to app chrome and wire with router.
- Update components that navigate using string paths to use named routes with slug.
- `src/views/HouseholdSettings.vue`: Invite members form wired to `sendInvitation(email)`. Members list rendered from `listMembers()` using the typed endpoint. (Completed)
- Adjust pages that call SDK/API to pass `{ householdSlug }` path params once client services are migrated.
Tests
- MSW handlers updated for path-scoped endpoints; Authorization header assertions retained where relevant.
- Router tests run under memory history.
- Legacy JS tests removed; TS suite covers auth, router, invitations, shopping mappings, and household flows.
---
## OpenAPI & Typing Considerations (Updated)
- Avoid `any`/`unknown` in app code; keep all API calls typed via `openapi-fetch`. Do not use ad-hoc property guards (e.g., hasProp/Reflect) in the SDK; rely on generated types plus decoders at the boundary.
- Household scoping: use typed path params (`{ params: { path: { householdSlug } } }`); do not mutate path strings.
- Auth refresh: returns `{ accessToken, tokenType }`. After refresh, call user/household endpoints to populate app state. Use `whoami` to validate the active routes slug when needed.
- Cleanup/migration tasks:
1) Remove X-Household-Slug header injection in `api/client.ts` and refactor services to accept `householdSlug` via typed params. (Completed)
2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; parse is now typed; Google OAuth remains raw for now.
3) Integrate the new `POST /shopping/current/ingredients` endpoint into the SDK (`requestIngredient(ingredientId: number)`) and expose via `useShopping`; refactor `MyShoppingPage.vue` accordingly and remove legacy stubs. (SDK + composable done; UI refactor next)
4) Identity: `User` remains the primary identity. `Person` has been removed; meal-related UIs use `MemberRef` exclusively.
---
## Migration Plan & Feature Flag
- Removed `VUE_APP_MULTITENANT_ENABLED` and legacy flat routes. The app now always uses slug-scoped routes.
---
## Acceptance Criteria (Summary)
- Users can create accounts, login (email/password), logout, and refresh sessions (token-only).
- All routes operate under `/:householdSlug` with correct redirects and deep link support.
- Active household is selectable and visible; API calls are correctly scoped via path params.
- Invitation token acceptance adds membership and navigates appropriately.
- Legacy persons identity is no longer used in auth; MemberRef used in meal UIs; tests updated and passing.
Google OAuth is planned next.
---
## Open Questions / Next Steps
- Add a user profile endpoint and load it post-refresh to populate `currentUser()` with real data instead of a placeholder.
- Replace temporary raw fetch calls (persons, parse, members listing) with typed endpoints when available.
- Implement Google OAuth login and account creation flows.
- MyShopping ad-hoc items (follow-ups):
- Add optional “edit adhoc item” support if backend provides a patch/update endpoint; currently in-place edits are local-only and not persisted.
- Remove now-unused legacy stubs from SDK/composable in a cleanup pass (non-functional, safe to delete).
- Remove the legacy username login shim (`login(username: string)`) and any fallback UI; standardize on email/password (and Google) only.
- Rollout flag: default `VUE_APP_MULTITENANT_ENABLED` to true across environments and plan removal of legacy flat routes and related tests once stable.

View file

@ -38,6 +38,9 @@ export async function currentUser(): Promise<User | null> {
} }
} }
// Legacy username login has been removed; use loginWithPassword instead.
// New multitenant-ready API surface
export async function loginWithPassword(email: string, password: string): Promise<User> { export async function loginWithPassword(email: string, password: string): Promise<User> {
const res = await api.POST('/api/v1/auth/login', { body: { email, password } }) const res = await api.POST('/api/v1/auth/login', { body: { email, password } })
if (!res.response.ok) { if (!res.response.ok) {

View file

@ -14,13 +14,10 @@ export async function acceptInvitation(token: string): Promise<Household> {
return { id: h.id, name: h.name, slug: h.slug } return { id: h.id, name: h.name, slug: h.slug }
} }
export async function createInviteLink(householdSlug: string): Promise<string> { export async function sendInvitation(householdSlug: string, email: string): Promise<void> {
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/invitations', { params: { path: { householdSlug } } }) const res = await api.POST('/api/v1/households/{householdSlug}/invitations', {
params: { path: { householdSlug } },
if (!response.ok) { body: { email },
throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`) })
} if (!res.response.ok) throw new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`)
const inviteLink = data?.invite_link }
if (typeof inviteLink !== 'string') throw new Error('Invalid response: missing invite_link')
return inviteLink
}

View file

@ -297,6 +297,15 @@ export async function deleteMeal(mealId: number | string): Promise<void> {
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
} }
// Shopping
// getMyShoppingList/saveMyShoppingList endpoints removed in v2; keep temporary stubs for legacy UI
export async function getMyShoppingList(): Promise<import('@/domain/types').Ingredient[]> {
return []
}
export async function saveMyShoppingList(ingredients: import('@/domain/types').Ingredient[]): Promise<import('@/domain/types').Ingredient[]> {
return ingredients
}
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> { export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
const householdSlug = requireSlug() const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/{list_id}', { params: { path: { householdSlug, list_id: Number(id) } } }) const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/{list_id}', { params: { path: { householdSlug, list_id: Number(id) } } })

View file

@ -462,6 +462,11 @@ export interface components {
/** Name */ /** Name */
name: string; name: string;
}; };
/** CreateInvitationBody */
CreateInvitationBody: {
/** Email */
email: string;
};
/** CurrentShoppingList */ /** CurrentShoppingList */
CurrentShoppingList: { CurrentShoppingList: {
/** Outstandingitems */ /** Outstandingitems */
@ -565,10 +570,15 @@ export interface components {
/** Recipeid */ /** Recipeid */
recipeId?: number | null; recipeId?: number | null;
}; };
/** InviteLinkResponse */ /** InvitationResponse */
InviteLinkResponse: { InvitationResponse: {
/** Invite Link */ /** Token */
invite_link: string; token: string;
/**
* Status
* @default pending
*/
status: string;
}; };
/** ListIngredientItem */ /** ListIngredientItem */
ListIngredientItem: { ListIngredientItem: {
@ -1336,7 +1346,11 @@ export interface operations {
}; };
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody: {
content: {
"application/json": components["schemas"]["CreateInvitationBody"];
};
};
responses: { responses: {
/** @description Successful Response */ /** @description Successful Response */
200: { 200: {
@ -1344,7 +1358,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["InviteLinkResponse"]; "application/json": components["schemas"]["InvitationResponse"];
}; };
}; };
403: components["responses"]["Problem403"]; 403: components["responses"]["Problem403"];

View file

@ -44,6 +44,7 @@
</form> </form>
</div> </div>
<!-- Legacy quick-login removed -->
<div v-else> <div v-else>
<p>This environment is configured without multi-tenancy enabled.</p> <p>This environment is configured without multi-tenancy enabled.</p>
</div> </div>
@ -98,6 +99,8 @@ async function onGoogleLogin() {
alert(e instanceof Error ? e.message : 'Google login not available') alert(e instanceof Error ? e.message : 'Google login not available')
} }
} }
// legacy login removed
</script> </script>
<style scoped> <style scoped>

View file

@ -38,7 +38,6 @@
<p class="ingredient-line"> <p class="ingredient-line">
<ingredient-line <ingredient-line
:ingredient="ingredient" :ingredient="ingredient"
@update-line="(ing, line) => emit('on-update-line', ing, line)"
@update-ingredient="updateIngredient" @update-ingredient="updateIngredient"
@update-product-link="updateProduct" @update-product-link="updateProduct"
/> />
@ -74,13 +73,11 @@ const emit = defineEmits<{
(e: 'on-add'): void (e: 'on-add'): void
(e: 'on-delete', ingredient: Ingredient): void (e: 'on-delete', ingredient: Ingredient): void
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void (e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
(e: 'on-update-line', ingredient: Ingredient, newLine: string): void
(e: 'on-editing', isEditing: boolean): void (e: 'on-editing', isEditing: boolean): void
}>() }>()
const editing = ref(props.editOnly ?? false) const editing = ref(props.editOnly ?? false)
async function updateProduct(): Promise<void> { async function updateProduct(): Promise<void> {
// parseProduct is not available in v2 API; ignore for now // parseProduct is not available in v2 API; ignore for now
} }
@ -95,7 +92,6 @@ function toggleEditing() {
editing.value = !editing.value editing.value = !editing.value
emit('on-editing', editing.value) emit('on-editing', editing.value)
} }
</script> </script>
<style scoped> <style scoped>

View file

@ -4,7 +4,6 @@
<input <input
v-model="ingredientText" v-model="ingredientText"
placeholder="Enter an ingredient" placeholder="Enter an ingredient"
@input="onInput"
@keyup.enter="updateIngredient" @keyup.enter="updateIngredient"
@blur="updateIngredient" @blur="updateIngredient"
> >
@ -31,7 +30,6 @@ import type { Ingredient } from '@/domain/types'
const props = defineProps<{ ingredient: Ingredient }>() const props = defineProps<{ ingredient: Ingredient }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update-line', ingredient: Ingredient, newLine: string): void
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void (e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
(e: 'update-product-link', ingredient: Ingredient, link: string): void (e: 'update-product-link', ingredient: Ingredient, link: string): void
}>() }>()
@ -59,20 +57,6 @@ function updateProductLink() {
emit('update-product-link', props.ingredient, productLink.value) emit('update-product-link', props.ingredient, productLink.value)
} }
} }
// Emit raw line changes so parent stays in sync even before parse
function isHtmlInput(el: EventTarget | null): el is HTMLInputElement {
return typeof HTMLElement !== 'undefined' && el instanceof HTMLInputElement
}
function onInput(e: Event) {
let val = ingredientText.value
const t = e.target
if (isHtmlInput(t)) {
val = t.value
}
if (val !== props.ingredient.line) emit('update-line', props.ingredient, val)
}
</script> </script>
<style scoped> <style scoped>

View file

@ -102,7 +102,6 @@
@on-add="addIngredient" @on-add="addIngredient"
@on-delete="deleteIngredient" @on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient" @on-update-ingredient="updateIngredient"
@on-update-line="updateIngredientLine"
@on-editing="onEditAdditionalIngredients" @on-editing="onEditAdditionalIngredients"
/> />
</div> </div>
@ -119,7 +118,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, onBeforeMount } from 'vue' import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toMealEdit } from '@/router/links'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk' import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders' import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth' import { currentUser } from '@/api/auth'
@ -153,13 +151,8 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { show: showAlert } = useAlert() const { show: showAlert } = useAlert()
// No child refs; parent maintains source of truth for lines
type PeopleKey = 'chefs' | 'consumers' | 'cleanup' type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
// Track which ingredient objects have unparsed edits so we only parse what changed
const dirtyLines = new Map<Ingredient, string>()
const meal = reactive<Meal>({ const meal = reactive<Meal>({
id: -1, id: -1,
suggestedDate: new Date(), suggestedDate: new Date(),
@ -213,23 +206,6 @@ function deleteIngredient(ingredient: Ingredient) {
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) { function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i)) meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
// Clear dirty status for this row (was parsed and replaced)
if (dirtyLines.has(ingredient)) dirtyLines.delete(ingredient)
}
function updateIngredientLine(ingredient: Ingredient, newLine: string) {
// Update the raw line immediately so Save has the latest text
const idx = meal.extraIngredients.indexOf(ingredient)
if (idx >= 0) {
// mutate in place to preserve object identity (used as dirtyLines key)
const target = meal.extraIngredients[idx]
if (target) target.line = newLine
} else {
// fallback (shouldn't generally happen)
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? { ...i, line: newLine } : i))
}
// Mark as dirty to parse later (on save) if needed
dirtyLines.set(ingredient, newLine)
} }
function removePerson(list: PeopleKey, person: MemberRef) { function removePerson(list: PeopleKey, person: MemberRef) {
@ -279,70 +255,19 @@ function scaleIngredients(mealRecipe: MealRecipe) {
} }
async function onSaveMeal() { async function onSaveMeal() {
try {
// Blur any focused input so its change handlers run
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 1) Drop any empty-line rows and clear their dirty flags
const nonEmpty: Ingredient[] = []
for (const ing of meal.extraIngredients) {
const line = typeof ing.line === 'string' ? ing.line.trim() : ''
if (line.length === 0) {
// also clear dirty if present
if (dirtyLines.has(ing)) dirtyLines.delete(ing)
continue
}
nonEmpty.push(ing)
}
meal.extraIngredients = nonEmpty
// 2) Build list of only the dirty lines that still exist in the array
const dirtyEntries: Array<{ ing: Ingredient; line: string }> = []
for (const [ing, line] of dirtyLines.entries()) {
// only consider ingredients still present
if (meal.extraIngredients.includes(ing)) {
const t = typeof line === 'string' ? line.trim() : ''
if (t.length > 0) dirtyEntries.push({ ing, line: t })
}
}
// 3) Parse only dirty lines
if (dirtyEntries.length > 0) {
const lines = dirtyEntries.map((e) => e.line)
const parsed = await (await import('@/api/sdk')).parseIngredients(lines)
// Replace corresponding rows by identity
parsed.forEach((p, idx) => {
const target = dirtyEntries[idx]?.ing
if (!target) return
const i = meal.extraIngredients.indexOf(target)
if (i >= 0) meal.extraIngredients.splice(i, 1, p)
// Clear dirty marker for this ingredient object
dirtyLines.delete(target)
})
}
// Final safety: drop any zero-quantity items (should be rare post-parse)
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => (typeof i.quantity === 'number' ? i.quantity > 0 : true))
const saved = await saveMeal(toMealInput(meal)) const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) { if (saved && saved.id >= 0) {
Object.assign(meal, saved) Object.assign(meal, saved)
router.push(toMealEdit(saved.id)) router.push(`/meals/${saved.id}`)
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' }) showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return return
} }
showAlert({ showAlert({
heading: 'Error saving meal', heading: 'Error saving meal',
message: 'An unknown error occurred while saving the meal', message: 'An error occurred while saving the meal',
type: 'error', type: 'error',
}) })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save meal'
showAlert({ heading: 'Error saving meal', message, type: 'error' })
}
} }
</script> </script>

View file

@ -12,8 +12,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { toRecipeAdd, toRecipeEdit } from '@/router/links'
import ActionItem from '@/components/ActionItem.vue' import ActionItem from '@/components/ActionItem.vue'
import RecipeSearchBox from './RecipeSearchBox.vue' import RecipeSearchBox from './RecipeSearchBox.vue'
import type { Recipe } from '@/domain/types' import type { Recipe } from '@/domain/types'
@ -21,12 +20,14 @@ import type { Recipe } from '@/domain/types'
const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString() const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
const router = useRouter() const router = useRouter()
const route = useRoute()
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
function onSelectRecipe(r: Pick<Recipe, 'id'>) { function onSelectRecipe(r: Pick<Recipe, 'id'>) {
router.push(toRecipeEdit(r.id)) router.push({ name: 'recipe-edit', params: { householdSlug: slug, id: r.id } })
} }
function onAddRecipe() { function onAddRecipe() {
router.push(toRecipeAdd()) router.push({ name: 'recipe-add', params: { householdSlug: slug } })
} }
</script> </script>

View file

@ -102,7 +102,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue' import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { toShoppingList } from '@/router/links'
import { useAlert } from '@/composables/useAlert' import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping' import { useShopping } from '@/composables/useShopping'
import { getUpcomingMeals } from '@/api/sdk' import { getUpcomingMeals } from '@/api/sdk'
@ -158,21 +157,13 @@ async function loadData() {
} }
async function mealSelected(meal: { id: number }) { async function mealSelected(meal: { id: number }) {
try { await requestMeal(meal.id)
await requestMeal(meal.id) await loadData()
await loadData()
} catch (err) {
showAlert({ type: 'error', heading: 'Failed to include meal', message: err instanceof Error ? err.message : 'Unknown error' })
}
} }
async function mealUnselected(meal: { id: number }) { async function mealUnselected(meal: { id: number }) {
try { await unrequestMeal(meal.id)
await unrequestMeal(meal.id) await loadData()
await loadData()
} catch (err) {
showAlert({ type: 'error', heading: 'Failed to un-include meal', message: err instanceof Error ? err.message : 'Unknown error' })
}
} }
async function markFound() { async function markFound() {
@ -192,7 +183,7 @@ async function markPurchased() {
return return
} }
selected.value = [] selected.value = []
router.push(toShoppingList(shopping.id)) router.push(`/shopping/${shopping.id}`)
} }
function toggleSelect(item: Group) { function toggleSelect(item: Group) {

View file

@ -43,11 +43,11 @@
</span> </span>
<span v-else-if="source.recipe && source.ingredient"> <span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="toRecipeEdit(source.recipe.id)">{{ <router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name source.recipe.name
}}</router-link> }}</router-link>
for for
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{ <router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -59,7 +59,7 @@
</span> </span>
<span v-else-if="source.meal && source.ingredient"> <span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for {{ source.ingredient.line }} for
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toMealPlan()">{{ <router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -84,11 +84,11 @@
</span> </span>
<span v-else-if="source.recipe && source.ingredient"> <span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="toRecipeEdit(source.recipe.id)">{{ <router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name source.recipe.name
}}</router-link> }}</router-link>
for for
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{ <router-link :to="`/meals/${source.meal?.id ?? ''}/`">{{
source.meal?.suggestedDate source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -100,7 +100,7 @@
</span> </span>
<span v-else-if="source.meal && source.ingredient"> <span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for {{ source.ingredient.line }} for
<router-link :to="toMealEdit(source.meal.id)">{{ <router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggestedDate source.meal.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -118,13 +118,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { toMealEdit, toRecipeEdit, toMealPlan, toRecipes } from '@/router/links'
import { ago } from '@/dateformats' import { ago } from '@/dateformats'
import { calculateTotals } from '@/units' import { calculateTotals } from '@/units'
import type { Group } from '@/composables/useShopping' import type { Group } from '@/composables/useShopping'
const props = defineProps<{ shoppingListItemGroup: Group }>() const props = defineProps<{ shoppingListItemGroup: Group }>()
// Slug resolved via link helpers using current household context
const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString() const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString()
const imageSrc = computed(() => { const imageSrc = computed(() => {

View file

@ -18,6 +18,8 @@ export async function loadUser() {
return user.value return user.value
} }
// Legacy username login removed; use loginWithPassword instead.
export async function loginWithPassword(email: string, password: string) { export async function loginWithPassword(email: string, password: string) {
user.value = await apiLoginWithPassword(email, password) user.value = await apiLoginWithPassword(email, password)
return user.value return user.value

View file

@ -62,6 +62,9 @@ export function useShopping() {
unrequestMeal: sdk.unrequestMeal, unrequestMeal: sdk.unrequestMeal,
requestIngredient: sdk.requestIngredient, requestIngredient: sdk.requestIngredient,
unrequestIngredient: sdk.unrequestIngredient, unrequestIngredient: sdk.unrequestIngredient,
getMyShoppingList: sdk.getMyShoppingList,
saveMyShoppingList: sdk.saveMyShoppingList,
// View-model helpers
groupsFrom, groupsFrom,
mealsFrom, mealsFrom,
async purchaseFromGroups(groups: Group[]) { async purchaseFromGroups(groups: Group[]) {

View file

@ -1,66 +0,0 @@
import type { RouteLocationRaw } from 'vue-router'
import { getHouseholdSlug } from '@/api/client'
// Centralized builders for slug-scoped routes to keep navigation consistent
// When an options object is provided, require an explicit slug.
// Callers may also omit the argument entirely to infer from context.
type WithSlug = { slug: string }
function resolveSlug(input?: WithSlug): string {
if (input && typeof input.slug === 'string') return input.slug
return getHouseholdSlug() ?? ''
}
// Pages without ids
export function toMealPlan(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'mealplan', params: { householdSlug: slug } }
}
export function toRecipes(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'recipes', params: { householdSlug: slug } }
}
export function toRecipeAdd(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'recipe-add', params: { householdSlug: slug } }
}
export function toMealAdd(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'meal-add', params: { householdSlug: slug } }
}
export function toShoppingCurrent(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'shopping-current', params: { householdSlug: slug } }
}
export function toHouseholdSettings(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'household-settings', params: { householdSlug: slug } }
}
// Pages with ids
type WithId = { id: number | string }
type IdOrOpts = number | string | (WithId & WithSlug)
export function toRecipeEdit(idOrOpts: IdOrOpts): RouteLocationRaw {
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
return { name: 'recipe-edit', params: { householdSlug: slug, id } }
}
export function toMealEdit(idOrOpts: IdOrOpts): RouteLocationRaw {
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
return { name: 'meal-edit', params: { householdSlug: slug, id } }
}
export function toShoppingList(idOrOpts: IdOrOpts): RouteLocationRaw {
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
return { name: 'shopping-list', params: { householdSlug: slug, id } }
}

View file

@ -3,14 +3,22 @@
<h1>Household Members</h1> <h1>Household Members</h1>
<section> <section>
<h2>Invite a member</h2> <h2>Invite a member</h2>
<p class="muted">Share an invite link with the person you want to add.</p> <form @submit.prevent="onInvite">
<button <label for="email">Email</label>
type="button" <input
:disabled="submitting" id="email"
@click="onCopyInviteLink" v-model="email"
> type="email"
Copy Invite Link required
</button> autocomplete="email"
>
<button
type="submit"
:disabled="submitting"
>
Send Invitation
</button>
</form>
<p <p
v-if="message" v-if="message"
class="message" class="message"
@ -47,18 +55,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { createInviteLink } from '@/api/invitations' import { sendInvitation } from '@/api/invitations'
import { listMembers, type Member } from '@/api/households' import { listMembers, type Member } from '@/api/households'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useAlert } from '@/composables/useAlert'
const email = ref('')
const submitting = ref(false) const submitting = ref(false)
const message = ref('') const message = ref('')
const error = ref('') const error = ref('')
const members = ref<Member[]>([]) const members = ref<Member[]>([])
const route = useRoute() const route = useRoute()
const householdSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null)) const householdSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
const { show: showAlert, scheduleAutoDismiss } = useAlert()
onMounted(async () => { onMounted(async () => {
try { try {
@ -68,20 +75,18 @@ onMounted(async () => {
} }
}) })
async function onCopyInviteLink() { async function onInvite() {
message.value = '' message.value = ''
error.value = '' error.value = ''
submitting.value = true submitting.value = true
try { try {
const slug = householdSlug.value const slug = householdSlug.value
if (!slug) throw new Error('No household selected') if (!slug) throw new Error('No household selected')
const inviteLink = await createInviteLink(slug) await sendInvitation(slug, email.value.trim())
await navigator.clipboard.writeText(inviteLink) message.value = 'Invitation sent.'
email.value = ''
showAlert({ type: 'success', heading: 'Success', message: 'Invite link copied to clipboard!' })
scheduleAutoDismiss(3000)
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to copy invite link' error.value = e instanceof Error ? e.message : 'Failed to send invitation'
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -17,7 +17,7 @@
</section> </section>
<section> <section>
<h2>Join an existing household</h2> <h2>Join an existing household</h2>
<p>To join a household, ask an existing member to share an invitation link with you.</p> <p>To join a household, ask an existing member to send an invitation to your email address.</p>
</section> </section>
</div> </div>
</template> </template>

View file

@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('slug-scoped navigation', () => {
it('feature routes are nested under :householdSlug and links use named routes', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => ({}))
const paths = router.getRoutes().map((r) => r.path)
expect(paths).toContain('/:householdSlug/recipes')
expect(paths).toContain('/:householdSlug/recipes/:id')
expect(paths).toContain('/:householdSlug/mealplan')
expect(paths).toContain('/:householdSlug/meals/add')
expect(paths).toContain('/:householdSlug/meals/:id')
expect(paths).toContain('/:householdSlug/shopping')
expect(paths).toContain('/:householdSlug/shopping/current')
expect(paths).toContain('/:householdSlug/shopping/:id')
// No flat feature routes
expect(paths).not.toContain('/recipes')
expect(paths).not.toContain('/meals/:id')
expect(paths).not.toContain('/shopping')
})
})

View file

@ -1,16 +0,0 @@
http://127.0.0.1:8000/
Server is running with watch enabled
# Dummy login
First account created. No specfic persona details.
- Email: specuser+20251102@example.com
- Display Name: Spec User
- Password: SpecPassw0rd!
- Household slug: faulconfridge-qa2
# Baker
- Email: patty.cake+20251102@example.com
- Display Name: Patty Cake
- Password: a-very-secure-password
- Household slug: the-rolling-scones
```