diff --git a/docs/FAS4-HOUSEHOLD-COLLAB-AUDIT.md b/docs/FAS4-HOUSEHOLD-COLLAB-AUDIT.md new file mode 100644 index 0000000..43f18a1 --- /dev/null +++ b/docs/FAS4-HOUSEHOLD-COLLAB-AUDIT.md @@ -0,0 +1,285 @@ +# 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.*