docs(4): uppdatera FAS4-hushållsaudit efter granskningsjusteringar

- GDPR: tydligt skilj lämna hushåll (åtkomst) från kontoradering.
- Token-säkerhet: sha256-hash i DB, single-use, 7 dagars expiry, max 10
  aktiva länkar, roll adult vid join, ingen owner-transfer.
- Analytics: aldrig token eller invite_code i properties.
- Webbfallback noterad som fas-1-spår utanför Fas 4.
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 18:10:03 +07:00
parent dcaae9f0ba
commit 7216417413
+285
View File
@@ -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.13. | **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: `<urlScheme>://households/join?token=<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: `<urlScheme>://households/join?token=<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.*