docs(12-03): complete household invitations and members plan

This commit is contained in:
Jakub Zych
2026-10-02 13:17:47 +02:00
parent 62863cf0ae
commit 6f37a3782f

View File

@@ -0,0 +1,278 @@
---
phase: 12-p-ytarium-api-collections-and-albums
plan: 03
subsystem: api
tags: [fonoteka, household, invitations, members, river, conga, postcard, lighthouse, notifications, parity, tide]
requires:
- phase: 12-p-ytarium-api-collections-and-albums
provides: "12-02: classes.Resolve/SwitchTo/ProvisionCollection/AccessibleByMembership, requestScope/readInput/writeWinterHTTPError, the fonoteka seed hook and fonoteka_reset.php"
- phase: 11-jobs-realtime-and-search-infrastructure
provides: conga Enqueue on a transaction, lighthouse Emit and memory driver, postcard catalog and memory driver
provides:
- classes.SendInvitation/ResendInvitation/CancelInvitation/AcceptInvitation/RemoveEditor, NormalizeInvitationEmail, ValidLaravelEmail
- classes.ErrInvitationUnavailable (410), ErrInvitationAcceptanceRequired (409), ErrInvalidInvitationEmail
- classes.ProvisionOrgFor (OrgProvisioner port)
- classes.WriteNotification/NotifyInvitationAccepted, NotificationPayload, NotificationType* constants
- classes.InvitationMailArgs (golem15.fonoteka.invitation_mail, queue mail, 3 attempts, token as app-key ciphertext)
- plugin pact.HasJobs (invitation mail worker) and pact.HasMailTemplates (collection_invitation, -en, plytarium layout)
- pending-invitation guard in Resolve and SwitchTo; writeResolveError/WriteResolveError mapping 404/409 at every resolver caller
- 7 household/invitation routes ported on the JWT group (63 ported routes)
- parity: nuxt-collections flow, TestFonotekaNuxtFlows, 64-hex invitation-token secret rule, share capture rules, php_parity.sh serve-mail/invite-token
affects: [12-04, 12-05, 13]
actuals:
tokens: 56421
tasks: 3
commits: 3
plan_head_before: 6c729a708f96fe941c4791049b89a9cad321461f
plan_head_after: f88c01e787ecb541c91aee0638c2c21b36d62e9a
tech-stack:
added: []
patterns:
- "A secret that must leave the request (invitation token) rides a River job enqueued with conga Enqueue on the write transaction, encrypted with lagoon.Encrypted; the worker decrypts and re-checks the sha256 before sending"
- "Notification writes insert the row with PHP json_encode text and Emit notification:new then notification:count on user:<id> on the same transaction"
- "Every PHP HttpException and every non-HTTP exception PHP answers in production is an embedded Winter page (winter_<status>.html); validation exceptions outside Laravel's JSON path are the 500 page"
- "Parity cases needing a raw token seed it on both sides (secret:invite in the private store, sha256 in the row); flows that mint one in PHP read it from the log mailer between two recording runs"
- "Route-qualified seed extras: fonotekaCaseExtras keys may be \"<route id>#<case id>\""
key-files:
created:
- ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/org_provisioner.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/laravel_email.go
- ../fonoteka.go/plugins/golem15/fonoteka/jobs.go
- ../fonoteka.go/plugins/golem15/fonoteka/mail.go
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation.htm
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/members_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_409.html
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_410.html
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_500.html
- ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go
- ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml
- ../fonoteka.go/parity/fonoteka_flows_test.go
modified:
- ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/{me_context,realtime_channels,collection_share,collections,token_api,oauth_consent}_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/parity/parity_test.go
- ../fonoteka.go/parity/parity_contract_test.go
- ../fonoteka.go/parity/fonoteka_seed_test.go
- ../fonoteka.go/parity/fonoteka_reset.php
- ../fonoteka.go/parity/synthetic_test.go
- ../fonoteka.go/parity/php_parity.sh
- ../fonoteka.go/parity/check_corpus.go
- ../fonoteka.go/parity/check_corpus_test.go
- ../fonoteka.go/parity/capture-rules.yaml
- ../fonoteka.go/parity/README.md
key-decisions:
- "Recorded PHP answers every invitation ValidationException (store required|email and normalizeEmail) with Winter's 500 'Blad strony' page, not a 422 JSON envelope: the Winter error handler renders any non-HTTP exception; the store reproduces that page"
- "The store's Laravel email rule is an egulias RFCValidation port (ValidLaravelEmail), distinct from lagoon's FILTER_VALIDATE_EMAIL email rule, so user@localhost passes the rule and fails normalizeEmail as in PHP"
- "Winter 409 and 410 are the generic error page with that status and a trailing newline (the 404 CMS page has none)"
- "InvitationMailArgs lives in classes (the service enqueues it); jobs.go aliases it and owns the worker; the worker skips a superseded or no longer pending invitation"
- "Route-level accept/resend cases seed invitations with a known token on both sides; only the Nuxt flow reads a PHP-minted token from the log mailer"
- "The nuxt-collections replay runs on its own database because the journey switches alice's context and would leak into later shared-database tests"
- "All resolver callers (me/context, realtime/channels, share, switch, genres, token store, OAuth consent) map errors through writeResolveError, so the 409 guard is a Winter page everywhere"
patterns-established:
- "classes.JobQueue (Enqueue signature of *conga.Manager) keeps services testable without a worker"
- "Smoke tests read river_job rows (args, broadcast events) instead of starting a worker, and call the job function directly with a memory mailer"
requirements-completed: [API-01]
coverage:
- id: D1
description: "Invite writes the pending invitation (fresh 64-hex token, sha256 at rest, 7-day expiry, reuse of the pending row) and enqueues the mail job in the same transaction with the token only as ciphertext; rollback leaves no row and no job; the worker sends the locale-picked template and link"
requirement: API-01
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestInvitationMailEnqueuedInTx"
status: pass
- kind: e2e
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (POST household/invitations: case, repeat, editor, org-less-owner, 4 validation cases)"
status: pass
human_judgment: false
- id: D2
description: "Accept adds the editor, moves the context, joins an org-less invitee to the inviter's organisation, stamps the invitation, clears pending registrations, writes the invitation_accepted notification with two user:<inviter> publications; concurrent accepts give one 200 and one 410"
requirement: API-01
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestInvitationAcceptAddsEditor"
status: pass
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestConcurrentAcceptSingleEditor"
status: pass
- kind: e2e
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (POST invitations/{token}/accept: missing, accepted, wrong-user, expired, revoked)"
status: pass
human_judgment: false
- id: D3
description: "Owner invitation and member management (index, cancel, resend, members, remove) with Winter 404 pages for editors and misses; removal repairs the member's context and detaches the organisation only without another household collection"
requirement: API-01
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestRemoveEditorRepairsContext"
status: pass
- kind: e2e
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (18 recorded household cases)"
status: pass
human_judgment: false
- id: D4
description: "Pending-invitation guard: a valid registration answers the Winter 409 page on resolve and switch; a stale one is deleted and the request continues"
requirement: API-01
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go#TestPendingInvitationGuard"
status: pass
- kind: e2e
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus (me/context pending-invitation 409, stale-registration 200)"
status: pass
human_judgment: false
- id: D5
description: "The Nuxt household journey replays against Go with PHP's bodies and DB effects; no raw invitation token can be committed to the corpus"
requirement: API-01
verification:
- kind: e2e
ref: "go -C ../fonoteka.go test ./parity -run TestFonotekaNuxtFlows/nuxt-collections"
status: pass
- kind: unit
ref: "../fonoteka.go/parity/check_corpus_test.go#TestCheckCorpusInvitationToken"
status: pass
- kind: other
ref: "go -C ../fonoteka.go run ./parity/check_corpus.go --require-recorded --check-secrets"
status: pass
human_judgment: false
- id: D6
description: "Invitation mail rendering (Polish and English templates, plytarium layout) looks right in a mail client"
verification: []
human_judgment: true
rationale: "Tests assert recipient, subject and link; the HTML layout's visual fidelity to the PHP mail (and the footer without the year) needs a human look"
duration: 36min
completed: 2026-10-02
status: complete
---
# Phase 12 Plan 03: Household invitations and members Summary
**Owner invitations by email with a transactional, encrypted-token River mail job, acceptance into the shared collection with the inviter's notification, member and invitation management, and the 409 pending-invitation guard: 7 routes ported byte-compatibly from 35 fresh PHP recordings plus the recorded Nuxt household journey.**
## Performance
- **Duration:** 36 min
- **Started:** 2026-10-02T10:40:14Z
- **Completed:** 2026-10-02T11:16:30Z
- **Tasks:** 3 (tracer verified end to end before expansion)
- **Files modified:** 72 in fonoteka.go (code, tests, 37 fixtures, docs)
## Accomplishments
- `SendInvitation` checks ownership (a personal token is refused in-handler too), normalizes the email like PHP 8 `strtolower(trim())` plus `FILTER_VALIDATE_EMAIL`, then in one transaction provisions the inviter's organisation, upserts the single pending invitation of (collection, email) with a fresh 64-hex token (sha256 at rest, 7 days) and enqueues `golem15.fonoteka.invitation_mail` with conga `Enqueue` (never `Dispatch`). The token sits in `river_job.args` only as `lagoon.Encrypted` ciphertext.
- The mail worker (`jobs.go`) decrypts, skips an invitation that is gone, no longer pending or superseded by a resend, and sends `collection_invitation` or `-en` (inviter's `preferred_locale`) with `{app.url}/zaproszenia/<token>` or `/en/invitations/<token>`.
- `AcceptInvitation` locks the invitation by hash, answers 410 for missing, accepted, revoked, expired or other-email invitations, adds the editor (granted by the inviter), moves the context, joins an org-less invitee to the inviter's organisation as member, stamps acceptance, clears pending registrations and writes `invitation_accepted` with `notification:new` and `notification:count` on `user:<inviter>`, all on one transaction.
- Resend rotates token and expiry and enqueues a new mail; cancel revokes; `RemoveEditor` deletes one editor row, repairs the member's context through the collection provisioner and detaches a non-owner only when no other household collection remains. No collection or album is ever deleted.
- `Resolve` and `SwitchTo` run the pending-invitation guard for JWT callers (409 page for a valid registration, stale row deleted). Every resolver caller now maps 404/409 through one helper.
- 35 PHP cases recorded under `APP_DEBUG=false` (the old debug accept fixture replaced); `TestParityCorpus` reports 63 ported routes passing; `nuxt-collections` (15 steps) replays green; `check_corpus.go --require-recorded --check-secrets` is green and now refuses any 64-hex value.
## Task Commits
All commits are in fonoteka.go (summercms.go code is untouched):
1. **Task 1: invite and accept (tracer)** - `bf0a9dd` (feat)
2. **Task 2: invitations and members management, pending-invitation guard** - `63774b6` (feat)
3. **Task 3: Nuxt household journey, validation recordings, secret rule** - `f88c01e` (test)
Ledger: fonoteka.go `6c729a7..f88c01e`, 3 commits (`git rev-list --count`).
## Files Created/Modified
- `classes/invitation_service.go`, `org_provisioner.go`, `notification_service.go`, `laravel_email.go`: the household services, OrgProvisioner, notification write path, Laravel email rule
- `classes/active_collection.go`: the pending-invitation guard
- `jobs.go`, `mail.go`, `views/mail/*`: invitation mail job and templates
- `controllers/api/invitations_controller.go`, `members_controller.go`, `winter_409/410/500.html`: handlers and Winter pages; resolver callers switched to `writeResolveError`
- `routes.go`: seven JWT-only routes with `{id}` constraints and `throttle:10,1` on store and resend
- `parity/*`: seed extras on both sides, manifest flips, 35 route fixtures, the flow, its replay test, the 64-hex secret rule, share capture rules, `serve-mail`/`invite-token`, README recipe
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Invitation validation is a 500 page, not a 422 envelope**
- **Found during:** Task 1 (probing PHP before recording)
- **Issue:** The plan expected Laravel's `{"message","errors"}` envelope. Recorded PHP answers `{}`, `not-an-email`, `user@localhost` and a padded email with Winter's 500 "Błąd strony" page: the Winter error handler renders any non-HTTP exception.
- **Fix:** `writeInvitationValidationFailed` writes the 500 page; four recorded cases pin it.
- **Commits:** bf0a9dd, f88c01e
**2. [Rule 1 - Bug] Laravel's email rule is not FILTER_VALIDATE_EMAIL**
- **Issue:** lagoon's `email` rule ports `FILTER_VALIDATE_EMAIL`; Laravel's rule is egulias RFCValidation (accepts `user@localhost`, quoted and UTF-8 local parts, refuses surrounding spaces).
- **Fix:** `classes.ValidLaravelEmail`, written against a PHP truth table of 45 addresses.
- **Commit:** bf0a9dd
**3. [Rule 2 - Missing critical] Test encryption key for the parity target**
- **Issue:** The parity Go target never loads `app.key`, so encrypting the job token failed.
- **Fix:** The parity `TestMain` publishes a test-only column key, as `serve` does from `app.key`.
- **Commit:** bf0a9dd
**4. [Rule 1 - Bug] Resolver errors beyond 404 were opaque 500s**
- **Issue:** The token store and OAuth consent mapped every resolver error to the opaque 500, and the new 409 needed a page everywhere.
- **Fix:** One `writeResolveError` (404, 409, else opaque 500) at all resolver callers, `WriteResolveError` for the genres controller.
- **Commit:** 63774b6
**5. [Rule 3 - Blocking] Flow replay leaked into later shared-database tests**
- **Issue:** The journey switches alice to a new collection; `TestOAuthFlows` (later in file order) then saw "Domowa półka" and three manual tokens.
- **Fix:** `nuxt-collections` replays on its own database (`isolatedFlowDB`).
- **Commit:** f88c01e
**6. [Rule 3 - Blocking] Seed extras keyed by route**
- **Issue:** Case ids such as `case`, `accepted` and `revoked` repeat across routes with different seed states.
- **Fix:** `fonotekaCaseExtra(routeID, caseID)` checks `"<route id>#<case id>"` first.
- **Commit:** bf0a9dd
**7. [Plan adjustment] Final context of the removed member**
- The plan expected bob's context on his own collection after removal. In the seed bob still edits alice's Parity Collection, which has the lower id, so the provisioner moves him there, as PHP's recording shows. The flow test asserts that.
**8. [Plan adjustment] Mail layout footer**
- Go templates have no date helper and postcard layouts receive only Content, Subject, css and brandCss, so the plytarium footer reads "© Płytarium." without the year.
**9. [Recording approach] Seeded tokens for route cases**
- Route-level accept, resend and guard cases seed the invitation with a known token on both sides (`secret:invite` in the private store). The log mailer path (`serve-mail`, `invite-token`) is used for the Nuxt flow, where PHP mints the token itself.
---
**Total deviations:** 6 auto-fixed (3 bugs, 1 missing critical, 2 blocking) plus 3 plan adjustments.
**Impact on plan:** All seven routes and the flow replay byte-compatibly; every deviation follows the recorded PHP contract or keeps the shared corpus honest. No scope creep.
## Issues Encountered
- Commits were made on `master` in fonoteka.go, as in 12-01 and 12-02 (`git.branching_strategy: none`), although the executor's default-branch guard reports `master` as protected.
- The isolated PHP ran under `php -S` (not `artisan serve`) so that the log mailer's stderr reaches `$PARITY_ROOT/mail.log`; it was stopped afterwards. Nothing was written into the PHP checkout. The private vars and mail log stay in `/tmp/summercms-parity/p1203` (mode 0600).
## Known Stubs
None.
## User Setup Required
None. Production needs `app.key` set (already required for encrypted columns) and the job worker running (`queue.work_in_serve` or `queue:work`) for invitation mail; the `mail` queue is picked up automatically from the registered job.
## Next Phase Readiness
- 12-04 can reuse `WriteNotification` for `notifyAlbumAdded` (ordered `NotificationPayload`, PHP json_encode text) and `writeResolveError` for album handlers.
- 12-05 has `householdHarness`, `invitationJobs`/`decryptJobToken`, `testInvitationMailer` and `ValidLaravelEmail`'s truth table to extend for coverage.
- Phase 13 consumes `PendingInvitationRegistration` on register and adds the notifications list on top of the rows written here.
---
*Phase: 12-p-ytarium-api-collections-and-albums*
*Completed: 2026-10-02*