Files
Cibello-app/docs/FAS4-HOUSEHOLD-COLLAB-AUDIT.md
T
Sven (AAMOS AI) 7216417413 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.
2026-08-07 18:10:03 +07:00

286 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.*