# Fas 4 – Hushållssamarbete: REUSE/GAP-matris och stegplan > Levereras före kod. Styrande dokument: utbyggnadsdokumentet §7 + `docs/03-funktionskatalog.md` + `docs/07-datamodell.md` + `docs/12-sakerhet-gdpr.md` + `docs/19-beslutslogg.md`. > > Audit först, ingen kod. Invänta godkännande innan 4a påbörjas. --- ## Hermetiska testregler (samma som Fas 3) Alla integrationstester ska vara hermetiska: - De får inte bero på innehållet i `.env` (värden som `AAMOS_MODE`, `EMAIL_MODE`, `S3_MODE` skrivs över i `setup-env.ts`). - De får inte bero på förkonfigurerad konfiguration eller externa tjänster. - De får inte bero på databasinnehåll som skapats utanför testet. Testkörningen ansvarar själv för att testdatabasen är migrerad **och** seedad innan testerna startar; seeden är idempotent. - Körning: `pnpm --filter @app/database run db:test-setup` (migrate + seed) ska alltid föregå API-testerna. --- ## 1. REUSE/GAP-matris mot befintligt hushållsflöde | §7-krav / område | Befintlig implementation | Status | Rekommenderad åtgärd | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **Hushållsgrundstruktur** | Tabellerna `households` och `household_members` finns. `households.invite_code` är unik. Roller: `owner`, `adult`, `member`, `child`. | **Finns** | Återanvänd oförändrat. Utöka med tidsbunden inbjudanslänk, inte ny inbjudningsmodell. | | **Gå med via kod** | `POST /v1/households/join` med `{ inviteCode }`. Nya medlemmar får roll `adult`. Plan-gräns `maxHouseholdMembers` kontrolleras. | **Finns** | Återanvänd. Utöka med djuplänk + QR så att koden kan fyllas i automatiskt, men backend-logiken är densamma. | | **Medlemslista** | `GET /v1/households/:id` returnerar medlemmar med `userId`, `displayName`, `role`, `portionFactor`, `joinedAt`. | **Finns** | Återanvänd. Lägg till UI för att visa/ändra. | | **Ändra roll** | `PATCH /v1/households/:id/members/:userId` med `role`. Endast `owner` får ändra roller. | **Finns** | Återanvänd. Säkerställ att `child` inte kan upphöjas till `owner`. | | **Ta bort medlem** | `DELETE /v1/households/:id/members/:userId`. Endast `owner` kan ta bort andra; själv kan lämna. | **Finns** | Återanvänd. GDPR: avgöra om matdata ska behållas eller anonymiseras (se avsnitt 4). | | **Portionsfaktor** | `household_members.portion_factor` per medlem, default 1, validerat 0.1–3. | **Finns** | Återanvänd. Exponera i mobil-UI för ägare/vuxna. | | **Delade vs privata data** | Lager, plan, inköpslista, matlådor, budget är hushållsdelat. Mål, allergier, smakprofil, måltidshistorik är individuellt (§7/§56). | **Finns** | Fortsätt. Inga ändringar. | | **Länkinbjudan / djuplänk** | Saknas. Idag måste användaren manuellt skriva in `inviteCode`. | **Saknas** | Inför tidsbunden inbjudnings-token (`household_invite_links`) med djuplänk via `brand.config.json` `urlScheme`. | | **QR-inbjudan** | Saknas. | **Saknas** | Generera QR-kod i mobilen som kodar samma djuplänk. Ingen ny backend-modell. | | **Återkalla inbjudan** | Saknas. När en kod/länk delats finns den kvar tills hushållet byter kod. | **Saknas** | Lägg till `revoked_at` på inbjudningslänk och/eller `invite_code_expires_at` på hushållet. | | **Anti-dubblettkartan: inga parallella invite- eller medlemsmodeller** | `households.invite_code` + `household_members` är navet. | **OK** | Behåll. Alla inbjudningsvägar ska peka mot samma `households.invite_code`. | | **Händelsespårning** | `HOUSEHOLD_MEMBER_ADDED` finns i `EVENT_TYPES`. | **Finns** | Lägg till `HOUSEHOLD_MEMBER_REMOVED`, `HOUSEHOLD_MEMBER_ROLE_CHANGED`, `HOUSEHOLD_INVITE_LINK_CREATED`, `HOUSEHOLD_INVITE_LINK_REVOKED`. | | **Analytics** | Analytics-events är consent-gatade via `trackProductAnalytics`. | **Finns** | Lägg till `household_member_added`, `household_member_removed`, `household_invite_link_used` (inga PII, inga fritext-fält). | | **Notifieringar till medlemmar** | `notifications`-tabell + push_tokens finns. | **Delvis** | Återanvänd. Skicka push när någon går med eller lämnar (opt-in via notis-samtycke). | | **Plan-gränser** | `loadEntitlements(app.db, owner.userId)` ger `maxHouseholdMembers`. | **Finns** | Återanvänd vid både kod- och länkinbjudan. | ### Slutsats av matrisen Hushållssamarbete är en **utbyggnad**, inte ett parallellsystem: - **Kärnan** (`households`, `household_members`, `invite_code`, roller, plan-gränser) finns redan. - **Det som saknas** är (a) djuplänk/QR för smidigare inbjudan, (b) tidsstyrning/återkallning av inbjudningar, (c) medlemskaps-händelser och analytics, (d) GDPR-klar hantering när medlem lämnar. --- ## 2. Anti-dubblettkartan – varför INGA nya parallella modeller ### 2.1 Varför inte en separat `household_invites`-tabell med egen kod? `households.invite_code` är redan unik och fungerar. Att skapa en separat tabell med egna koder skulle ge: - Dubbla källor på sanning: vilken kod gäller? - Dubbla join-endpoints och valideringar. - Risk att gamla och nya inbjudningar krockar. **Beslut:** Använd `households.invite_code` som kanonisk permanent kod (multi-use fallback). En separat `household_invite_links`-tabell får endast lagra **metadata om länken**: `tokenHash`, `createdBy`, `expiresAt`, `revokedAt`, `usedAt`. Själva inbjudningskoden hämtas alltid från `households.invite_code` när länken löses in. ### 2.2 Varför inte en separat `household_member_history`-tabell? Medlemskapet är PK på `(household_id, user_id)`. Aktuellt medlemskap finns i `household_members`. Om vi behöver historik (vem var medlem när?) kan vi använda: - `audit_logs` (som redan loggar `household.member_removed`) - `domain_events` (`HOUSEHOLD_MEMBER_ADDED`, `HOUSEHOLD_MEMBER_REMOVED`) Ingen ytterligare tabell behövs i Fas 4. ### 2.3 Varför inte en separat `household_permissions`-tabell? Rollerna `owner`, `adult`, `member`, `child` är tillräckligt granulära för Fas 4. Rollbaserade behörigheter hårdkodas i routes (som idag). Om vi senare behöver finare rättigheter kan vi migrera då. --- ## 3. STEGPLAN ### Steg 4a – Djuplänk/QR för inbjudan **Mål:** En användare kan trycka på en länk/QR och automatiskt hamna i appen med inbjudningskoden ifylld. **Ändringar:** 1. Ny tabell `household_invite_links`: - `id` uuid PK - `householdId` FK → households - `tokenHash` text unik, sha256 av slumpmässigt token (≥128 bitar, base64url) - `createdBy` FK → users - `expiresAt` timestamp with time zone (default nu()+7 dagar) - `revokedAt` timestamp with time zone nullable - `usedAt` timestamp with time zone nullable - `createdAt`, `updatedAt` - **Token lagras ALDRIG i klartext i DB.** Uppslag görs via `sha256(token)`. 2. Djuplänk: `://households/join?token=` där `urlScheme` läses ur `brand.config.json` (aldrig hårdkodat). 3. Ny endpoint `POST /v1/households/join-link`: - Tar `{ token }`. - Slår upp `household_invite_links` → hushåll via `tokenHash = sha256(token)`. - Kontrollerar `expiresAt`, `revokedAt`, `usedAt`. - Återanvänder samma plan-gräns- och join-logik som `/v1/households/join`. - Efter lyckad join: markera länken `usedAt`. 4. Ny endpoint `POST /v1/households/:id/invite-links` (owner/adult): - Skapar en ny länk med 7 dagars giltighet. - Begränsa antalet aktiva länkar per hushåll (t.ex. max 10). - Returnerar endast token EN GÅNG (i svaret). Servern sparar bara hash. - Roll vid join via länk är alltid `adult` — aldrig `owner`. - **Owner-transfer ingår INTE i Fas 4.** 5. `GET /v1/households/:id/invite-links` (owner/adult): lista aktiva länkar med `expiresAt` (utan token, såklart). 6. `DELETE /v1/households/:id/invite-links/:linkId` (owner/adult): återkalla (sätt `revokedAt`). **Berörda filer:** - `packages/database/src/schema/households.ts` (`household_invite_links`) - `packages/validation/src/household.ts` (nya schemas) - `apps/api/src/routes/households.ts` - `apps/api/src/lib/helpers.ts` (`generateInviteLinkToken`, `hashInviteLinkToken`) - `apps/mobile/src/app/household.tsx` (QR + dela-länk) - `apps/mobile/src/app/household-join.tsx` (ny skärm för att ta emot länk) - `apps/mobile/app.json` / deep-linking-konfiguration - `apps/mobile/src/locales/*/common.json` (+12) **Migration:** `0019_household_invite_links.sql` **Testplan:** - Skapa länk → token returneras EN gång. - Länk med giltig token → användare blir medlem med roll `adult`. - Utgången länk → 410 Gone med i18n-nyckel `household.inviteExpired`. - Återkallad länk → 410 Gone med i18n-nyckel `household.inviteRevoked`. - Redan använd länk → 410 Gone med i18n-nyckel `household.inviteUsed`. Länkar är **engångsbruk** (`usedAt`). - Plan-gräns nådd → 402 Payment Required (samma som kod).maxHouseholdMembers. - Max aktiva länkar överskridet → 429. - Token återfinns inte i DB i klartext. --- ### Steg 4b – Medlemshantering i mobilen **Mål:** Ägare/vuxna kan se medlemmar, ändra roll, ta bort medlem; alla kan lämna hushållet. **Ändringar:** 1. Uppdatera `apps/mobile/src/app/household.tsx`: - Visa medlemmar med roll och portionsfaktor. - Ägare kan ändra roll och ta bort (utom sig själv om enda ägaren). - Varje medlem kan lämna hushållet. 2. Lägg till bekräftelsedialoger för borttag/rolländring. 3. Använd befintliga endpoints: - `PATCH /v1/households/:id/members/:userId` - `DELETE /v1/households/:id/members/:userId` **Berörda filer:** - `apps/mobile/src/app/household.tsx` - `apps/mobile/src/locales/*/common.json` (+12) **Migration:** ingen. **Testplan:** - Ägare ändrar `member` → `adult`. - Ägare tar bort medlem → medlem försvinner. - Enda ägaren kan inte ta bort sig själv (409). - `child` kan inte ändra andras roller (403). --- ### Steg 4c – Händelser, analytics och notiser **Mål:** Hushållsförändringar syns i domain_events, analytics och (opt-in) push-notiser. **Ändringar:** 1. Lägg till i `EVENT_TYPES`: - `HOUSEHOLD_MEMBER_REMOVED` - `HOUSEHOLD_MEMBER_ROLE_CHANGED` - `HOUSEHOLD_INVITE_LINK_CREATED` - `HOUSEHOLD_INVITE_LINK_REVOKED` 2. Lägg till analytics-events (inga PII, inga fritext-fält): - `household_member_added` - `household_member_removed` - `household_invite_link_used` 3. Emit events vid: - join (både kod och länk) - borttag - rolländring - länk skapad/återkallad 4. Push-notis (opt-in via notis-samtycke) till övriga medlemmar när någon går med eller lämnar. **Berörda filer:** - `packages/shared-types/src/enums.ts` - `packages/events/src/index.ts` - `packages/analytics/src/builders.ts` - `apps/api/src/routes/households.ts` - `apps/api/src/lib/notifications.ts` (om sådan finns) **Migration:** `0020_expand_event_types_household.sql` **Testplan:** - Efter join: `HOUSEHOLD_MEMBER_ADDED` + analytics. - Efter borttag: `HOUSEHOLD_MEMBER_REMOVED` + audit-log. - Efter rolländring: `HOUSEHOLD_MEMBER_ROLE_CHANGED`. - Consent-gate: analytics skickas bara om användaren samtyckt. --- ## 4. RISKER | Risk | Påverkan | Minskning | | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **GDPR: data när medlem lämnar** | Hög | Att lämna/tas bort ur ett hushåll är **inte kontoradering**. Vid borttag av `household_members`-raden tas endast **åtkomsten** bort. Personens `meals`, `taste_signals`, `memory_items`, `user_health_profiles` och `push_tokens` tillhör kontot och rörs inte — användaren kan gå med i ett annat hushåll. Hushållets historiska aggregat (budget, svinn, gemensamma matlådor, inventory-transaktioner) ändras inte retroaktivt. Radering av persondata sker endast via den befintliga kontoraderingsprocessen (`DELETE /v1/me`, dokumenterad i docs/12). Beslutet skrivs in explicit innan 4b kodas. | | **Behörigheter: vem får bjuda in/ta bort** | Hög | Bjud in: `owner` och `adult`. Ta bort andra: endast `owner`. Ta bort sig själv: alla utom enda ägaren. Ändra roller: endast `owner`. `child` får aldrig bjuda in eller ta bort. | | **Inbjudningslänkar läcker** | Medel | Token är slumpmässig ≥128 bitar, tidsbegränsad, engångsbruk. Servern lagrar endast sha256(token). Återkallning sätts omedelbart. Inga PII i länken. | | **Parallella inbjudningsmodeller** | Hög | Enda kanoniska koden är `households.invite_code`. `household_invite_links` är endast metadata. `/v1/households/join` och `/v1/households/join-link` konvergerar till samma join-kärna. | | **Plan-gränser kringgås** | Medel | Både kod- och länkinbjudan kontrollerar `maxHouseholdMembers` mot ägarens entitlements före insert. | | **i18n-paritet fallerar** | Låg | Alla nya nycklar läggs i samtliga 12 locales och testas med `apps/mobile/test/i18n.test.ts`. | | **Djuplänk hårdkodar app-namn** | Medel | `urlScheme` läses dynamiskt från `brand.config.json`. Ingen hårdkodning av app-namn. | | **Testdata läcker mellan tester** | Låg | Hermetiska tester skapar egna hushåll per test; `invite_code` genereras unikt. | --- ## 5. Översiktliga berörda tabeller | Tabell | Förändring | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `households` | Ingen schemaändring. `invite_code` fortsätter vara kanonisk. | | `household_members` | Ingen schemaändring. Befintlig roll- och delete-logik återanvänds. | | `household_invite_links` | **Ny** (metadata om djuplänkar). | | `users` | Ingen ändring, men vid kontoradering används befintligt GDPR-flöde (docs/12). | | `domain_events` | Nya event-typer: `HOUSEHOLD_MEMBER_REMOVED`, `HOUSEHOLD_MEMBER_ROLE_CHANGED`, `HOUSEHOLD_INVITE_LINK_CREATED`, `HOUSEHOLD_INVITE_LINK_REVOKED`. | | `audit_logs` | Återanvänds för `household.member_removed` (finns redan). | | `notifications` / `push_tokens` | Återanvänds för opt-in-notiser. | --- ## 6. Leveransordning 1. **Steg 4a:** djuplänk/QR för inbjudan (`household_invite_links`). 2. **Steg 4b:** medlemshantering i mobilen. 3. **Steg 4c:** händelser, analytics och notiser. Per steg: `brand-guard`, `typecheck`, `test`, `build`, `first-deploy staging`, patch + bundle. --- ## 7. Länk-invites – detaljerade regler ### 7.1 Token och djuplänk - Token: slumpmässig ≥128 bitar, base64url → 22 tecken. Skickas endast till klienten vid skapande. - Servern lagrar **sha256(token)** i `household_invite_links.tokenHash`; uppslag vid inbjudan görs via hash. - Djuplänk: `://households/join?token=` där `urlScheme` kommer från `brand.config.json`. - Giltighet: 7 dagar (`expiresAt`). - Engångsbruk: efter lyckad join sätts `usedAt`. Länkar är **single-use by design**. - Återkallning: `owner` eller `adult` sätter `revokedAt`. - Max 10 aktiva länkar per hushåll. - Roll vid join: alltid `adult` — aldrig `owner`. - **Owner-transfer ingår inte i Fas 4.** ### 7.2 Ingen PII i länken Länken innehåller endast token. Inget hushållsnamn, ingen e-post, inga medlemsnamn. Den som har länken kan gå med, därför ska användare dela den via säkra kanaler. ### 7.3 Token-säkerhet och användningsregler - Token genereras slumpmässigt med ≥128 bitar. - Servern lagrar **endast sha256(token)** i `tokenHash`; klartext-token returneras bara vid skapande. - Länkar är **single-use by design**: `usedAt` sätts vid lyckad join. - Giltighetstid: 7 dagar. - Max 10 aktiva länkar per hushåll. - Roll vid join via länk: alltid `adult` — aldrig `owner`. - **Owner-transfer ingår inte i Fas 4.** ### 7.4 Konvergens med kod-inbjudan Både `POST /v1/households/join` (kod) och `POST /v1/households/join-link` (token) ska i slutändan anropa samma interna `joinHouseholdCore(db, householdId, userId, role)` som: - kontrollerar plan-gräns, - sätter in `household_members` med angiven roll (`adult` för länk), - emittar `HOUSEHOLD_MEMBER_ADDED`, - loggar analytics (consent-gatat), - skickar notis till övriga medlemmar (opt-in). ### 7.5 i18n Nya nycklar för 4a: - `household.inviteLink`, `household.inviteLinkShare`, `household.inviteLinkCopied` - `household.inviteExpired`, `household.inviteRevoked`, `household.inviteUsed` - `household.qrScanHint` Nya nycklar för 4b: - `household.leaveConfirmTitle`, `household.leaveConfirmBody` - `household.removeMemberConfirmTitle`, `household.removeMemberConfirmBody` - `household.changeRoleConfirmTitle` Alla läggs i samtliga 12 locales och testas med `apps/mobile/test/i18n.test.ts`. ### 7.6 Analytics - `household_member_added`: `{ household_id_hash, role, via: 'code' | 'link' }` — hashat hushålls-id, inga personuppgifter. - `household_member_removed`: `{ household_id_hash }`. - `household_invite_link_used`: `{ household_id_hash, link_id }` — **aldrig token eller invite_code**. Alla går via `trackProductAnalytics` och är därmed consent-gatade. ### 7.7 Webbfallback Om mottagaren inte har appen installerad och trycker på länken i en webbläsare krävs en associerad domän + universal link / app link. Detta är ett **fas-1-spår** som ligger utanför Fas 4. I Fas 4 förutsätts att mottagaren har Expo Go/appen installerad. --- _Rapport färdig. Inväntar godkännande innan kod påbörjas._