Skip to content

Commit 76a4fdb

Browse files
committed
Flesh out milestone 5 planning docs
1 parent 2b1d15d commit 76a4fdb

7 files changed

+80
-9
lines changed

docs/SPEC.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Non-negotiables:
8484
- optional expiry
8585
- optional max uses
8686
- Current redemption use case is `BETA_SIGNUP`.
87+
- Generated by the app, not typed in as custom raw values by admins in Milestone 5.
8788
- Future code purposes can reuse the same domain without changing the public-user auth model.
8889

8990
### Books
@@ -147,6 +148,10 @@ Admin assigns books to these sections for the club.
147148
- Internal admins bypass public signup-completion checks, but only for admin routes.
148149
- Public Google-authenticated users cannot access `/admin/*`.
149150
- Internal admins should be redirected away from `/signup` and reader-facing product routes.
151+
- Route behavior defaults:
152+
- signed-out access to `/admin/*` redirects to `/admin/signin`
153+
- signed-in public-user access to `/admin/*` returns the app’s forbidden experience
154+
- signed-in internal-admin access to `/signup`, `/books/*`, `/clubs/*`, `/me/*`, and `/users/*` redirects to `/admin/invitation-codes`
150155

151156
### Admin Panel
152157
- Only internal admins can:
@@ -244,6 +249,7 @@ Schema and migration workflow:
244249
- `providerUserId = normalized email`
245250
- email/password login
246251
- manual creation in Supabase UI
252+
- no `auth_accounts` row required
247253
- `nickname` is immutable, unique, lowercase, and URL-safe.
248254
- `favoriteGenres` is stored as a validated flat list even though the UI groups Fiction and Non-Fiction separately.
249255
- `invitation_codes` stores:
@@ -255,7 +261,13 @@ Schema and migration workflow:
255261
- optional `maxUses`
256262
- `createdById`
257263
- `invitation_code_redemptions` records each successful code use and supports usage counting and audit history.
264+
- `maxUses = null` means unlimited use; `expiresAt = null` means no expiry.
265+
- Invitation-code status shown in the admin UI is derived from:
266+
- `isActive`
267+
- expiry state
268+
- successful redemption count versus `maxUses`
258269
- Signup code validation and redemption must be transactional so exhausted or inactive codes cannot be raced.
270+
- Successful signup redemption creates exactly one redemption row and is the only event that increases usage count.
259271
- Private club invitations target `invitedUserId`; the invite UI resolves nickname to that user on the server.
260272
- Signup invitation codes and private club invites are separate domains with separate persistence and rules.
261273
- Personal shelves are independent of clubs by design.
@@ -294,6 +306,7 @@ Store in DB:
294306
- Internal admins are not created through public signup.
295307
- Internal admins are created manually in Supabase UI.
296308
- Internal admin users use `provider = 'internal'`.
309+
- Internal admin passwords use a bcrypt-compatible hash stored in `users.password_hash`.
297310

298311
### 7.3 Implementation (Auth.js / NextAuth)
299312
- Use:
@@ -312,6 +325,12 @@ Store in DB:
312325
- incomplete public user
313326
- completed public user
314327
- internal admin
328+
- Internal-admin bootstrap should be documented with the exact Supabase fields to populate manually:
329+
- `provider`
330+
- `provider_user_id`
331+
- `email`
332+
- `password_hash`
333+
- optional `name`
315334

316335
### 7.4 Auth Rules
317336
- Public routes:
@@ -335,7 +354,7 @@ Store in DB:
335354
- `/auth/error` — auth failure state
336355
- `/signup` — completed-signup onboarding form for authenticated but incomplete public users
337356
- `/admin/signin` — internal admin sign-in
338-
- `/admin` — admin landing (can redirect to invitation-code management in Milestone 5)
357+
- `/admin` — admin landing that redirects to `/admin/invitation-codes` in Milestone 5
339358
- `/admin/invitation-codes` — internal invitation-code management
340359
- `/books/search` — search Google Books + add to shelves/clubs
341360
- `/books/[googleVolumeId]` — book details (from DB or fetched+cached; store book data in our DB after any user searches for it or tries to add it to a club/shelf)
@@ -422,18 +441,19 @@ Admin Invitation Codes (`/admin/invitation-codes`)
422441
- List codes with:
423442
- purpose
424443
- label
425-
- active/inactive state
444+
- derived status (`ACTIVE`, `INACTIVE`, `EXPIRED`, `EXHAUSTED`)
426445
- usage count
427446
- optional expiry
428447
- optional max uses
429448
- creator
430449
- Create new code with:
431-
- purpose
450+
- purpose (`BETA_SIGNUP` only in Milestone 5 UI)
432451
- label
433452
- optional expiry
434453
- optional max uses
435-
- Show the raw code once after creation.
454+
- Generate the raw code server-side and show it once after creation.
436455
- Allow activation/deactivation.
456+
- Do not support editing the raw code, expiry, or max uses after creation in Milestone 5.
437457
- Show redemption history or usage details sufficient to explain exhausted or inactive state.
438458

439459
Club Home (`/clubs/[clubId]`)
@@ -542,15 +562,17 @@ Navigation
542562
- required at signup completion
543563
- must match an active `BETA_SIGNUP` code
544564
- must respect expiry and max-uses rules when those are configured
565+
- successful redemption is counted only once per user
545566
- Internal admin sign-in:
546567
- valid email required
547568
- password required
548-
- credentials verified against stored password hash
569+
- credentials verified against stored bcrypt-compatible password hash
549570
- Invitation code creation:
550571
- `purpose` required
551572
- `label` required
552573
- `expiresAt` optional and, if present, must be a future timestamp
553574
- `maxUses` optional and, if present, must be a positive integer
575+
- raw code value is generated by the server
554576
- Club name: 2–60 chars
555577
- Thread title: 2–120 chars
556578
- Post body: 1–10,000 chars
@@ -592,6 +614,7 @@ Navigation
592614
- for shelves, always resolve ownership internally by userId after nickname lookup
593615
- Provider email must not be used as the authoritative public identity for authorization or invite acceptance.
594616
- Public users cannot access `/admin/*`, and internal admins cannot use the public onboarding path.
617+
- Deactivating or expiring an invitation code does not revoke access for users who already redeemed it successfully.
595618

596619
## 13) MVP Milestones
597620

@@ -685,8 +708,17 @@ Use `sortOrder`:
685708
- Internal admins use:
686709
- `provider = 'internal'`
687710
- `providerUserId = normalized email`
688-
- password-hash verification
711+
- bcrypt-compatible password-hash verification
712+
- Manual Supabase bootstrap should populate:
713+
- `provider`
714+
- `provider_user_id`
715+
- `email`
716+
- `password_hash`
717+
- optional `name`
718+
- Internal admin bootstrap does not require inserting an `auth_accounts` row.
689719
- Internal admins do not use public signup or public product routes in Milestone 5.
690720
- Invitation codes are modeled for future reuse through `purpose`, optional expiry, and optional max uses, but only `BETA_SIGNUP` is redeemed in Milestone 5.
691-
- Raw invitation codes are shown once at creation and then only the hash remains persisted.
721+
- Raw invitation codes are system-generated, shown once at creation, and then only the hash remains persisted.
722+
- `maxUses = null` means unlimited use; `expiresAt = null` means no expiry.
723+
- Admins can activate/deactivate codes, but editing code body, expiry, or max uses after creation is out of scope in Milestone 5.
692724
- Milestone 5 private club invites can only target existing signed-up public users with a nickname.

docs/tasks/milestone-5/milestone-5-beta-onboarding-admin-auth-and-invitation-codes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ Deliver the closed-beta onboarding and service-native identity workflow on top o
2222
- Internal admins are manually created in Supabase UI and stored with `provider = 'internal'`.
2323
- Internal admins use email/password only and do not participate in public signup or the public product routes.
2424
- Invitation codes are future-ready through `purpose`, optional expiry, and optional max uses, but only `BETA_SIGNUP` redemption is implemented in Milestone 5.
25+
- Invitation codes are system-generated by the app and shown in raw form only once at creation time; admins do not type custom raw code values in Milestone 5.
26+
- Invitation-code status in the admin UI is derived from `isActive`, `expiresAt`, and successful redemption count so admins can distinguish active, inactive, expired, and exhausted codes.
2527
- Nickname is immutable in Milestone 5 and is validated as a lowercase URL-safe handle.
2628
- Private club invites remain a separate domain from admin-managed invitation codes.
2729
- Milestone 5 does not add a public profile route, nickname change UI, or in-app admin-user bootstrap flow.
30+
- Signed-in public users who try to access `/admin/*` should see a forbidden experience, while internal admins who try to enter `/signup` or reader-app routes should be redirected back to `/admin/invitation-codes`.
2831

2932
## Delivery Order
3033
1. [Task 01: User and Admin Identity Foundation](./task-01-user-and-admin-identity-foundation.md)
@@ -38,6 +41,7 @@ Deliver the closed-beta onboarding and service-native identity workflow on top o
3841
- Completing signup persists nickname, gender, country, favorite genres, and signup completion state, and atomically redeems a valid `BETA_SIGNUP` invitation code.
3942
- Internal admins can sign in through `/admin/signin` and manage invitation codes from `/admin/invitation-codes`.
4043
- Invitation codes are stored hashed, support active/inactive state, and can optionally expire or cap uses.
44+
- Manual Supabase bootstrap requirements for internal admins are documented clearly enough that an operator can create an internal admin without app-side seeding.
4145
- Nickname becomes the default user-facing identity across `/me`, shelves, clubs, threads, reviews, and invite pages.
4246
- Public shelf sharing works by nickname route while preserving the existing signed-in-only public shelf access model.
4347
- Private club invites are created by nickname and accepted only by the targeted signed-in public user.

docs/tasks/milestone-5/task-01-user-and-admin-identity-foundation.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ Add the schema, shared validation, repository contracts, and identity helpers ne
2828
- `InvitationCodeRecord`
2929
- `InvitationCodeRedemptionRecord`
3030
- public user vs internal admin session identity
31+
- Define shared status helpers for invitation-code lifecycle evaluation:
32+
- active
33+
- inactive
34+
- expired
35+
- exhausted
3136
- Update auth and user repository contracts so app identity no longer depends on provider email for normal signed-in flows.
3237

3338
## Implementation Notes
@@ -36,22 +41,33 @@ Add the schema, shared validation, repository contracts, and identity helpers ne
3641
- Internal admins are represented by:
3742
- `provider = 'internal'`
3843
- `provider_user_id = normalized email`
39-
- password-hash verification
44+
- bcrypt-compatible password-hash verification
4045
- Internal admins are manually created in Supabase UI and are not provisioned by the app.
4146
- OAuth-linked public users still use `auth_accounts`; internal credentials auth can resolve directly from `users`.
47+
- Internal admin records should not require `auth_accounts` rows in Milestone 5.
48+
- This task should document the exact manual Supabase bootstrap fields for internal admins:
49+
- `provider`
50+
- `provider_user_id`
51+
- `email`
52+
- `password_hash`
53+
- optional `name`
4254
- Invitation codes must be stored hashed and modeled for future purposes even though only `BETA_SIGNUP` is redeemed in this milestone.
55+
- `maxUses = null` means unlimited use; `expiresAt = null` means no expiry.
56+
- Successful redemption count is derived from `invitation_code_redemptions`; failed validation attempts do not create redemption rows.
4357

4458
## Acceptance Criteria
4559
- The database schema and app-facing types expose all required Milestone 5 public-user, internal-admin, and invitation-code fields.
4660
- Shared validation covers nickname normalization, allowed genre values, required country/gender selection, internal admin auth inputs, and invitation-code hashing contracts.
4761
- Reusable repository helpers can distinguish incomplete public users, completed public users, and internal admins without route-local duplication.
4862
- Shared display helpers make nickname the first-class reader-facing identity.
4963
- Shared code-management types are stable enough to support future invitation-code purposes without revisiting the schema shape.
64+
- Manual internal-admin bootstrap requirements are explicit enough that implementation does not need a second design pass for Supabase setup.
5065

5166
## Expected Touchpoints
5267
- `db/schema/data.sql`
5368
- `db/migrations/*`
5469
- `types/db/index.ts`
5570
- `lib/auth/*`
71+
- `lib/invitation-codes/*`
5672
- `tests/unit/auth*.test.ts`
5773
- invitation-code validation and repository helpers

docs/tasks/milestone-5/task-02-signup-completion-and-public-app-auth-gating.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Implement the completed-signup flow so authenticated but incomplete public users
1919
- direct protected-page hits by incomplete public users
2020
- Redirect incomplete public users away from reader-facing protected routes and protected mutations until `signup_completed_at` is set.
2121
- Redirect completed public users away from `/signup` to the callback destination or `/books/search`.
22+
- Ensure internal admins never enter the public signup flow and are redirected to `/admin/invitation-codes` instead.
2223

2324
## Implementation Notes
2425
- Keep Google OAuth as the only public-user provider for Milestone 5, but treat OAuth and completed signup as separate lifecycle steps.
@@ -28,13 +29,15 @@ Implement the completed-signup flow so authenticated but incomplete public users
2829
- Invite links and other protected deep links should survive the onboarding detour by preserving callback intent.
2930
- Sign-out remains available to incomplete public users.
3031
- Invitation-code redemption should record audit history and enforce inactive, expired, exhausted, and wrong-purpose rejections on the server.
32+
- A successful signup redemption should consume one available use exactly once, even under concurrent submission attempts.
3133

3234
## Acceptance Criteria
3335
- A newly authenticated but incomplete public user is redirected to `/signup` before any reader-facing app surface renders.
3436
- Completing signup writes the required profile fields, marks signup complete, redeems the code, and redirects the user back to the intended destination.
3537
- Protected reader-facing server actions reject incomplete public users consistently.
3638
- Completed public users cannot accidentally return to `/signup` as a normal app page.
3739
- Invalid, inactive, expired, exhausted, and wrong-purpose invitation codes are rejected with clear server-enforced behavior.
40+
- Internal admins cannot complete public signup and are redirected back to the admin panel instead.
3841

3942
## Expected Touchpoints
4043
- `app/signup/page.tsx`
@@ -43,4 +46,5 @@ Implement the completed-signup flow so authenticated but incomplete public users
4346
- `app/api/auth/[...nextauth]/route.ts`
4447
- `proxy.ts`
4548
- `lib/auth/server.ts`
49+
- signup-completion repository helpers and tests
4650
- signup-completion server actions and tests

docs/tasks/milestone-5/task-03-internal-admin-auth-and-invitation-code-management.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ Add the internal-only auth path and admin surface needed to manage invitation co
55

66
## Scope
77
- Add `/admin/signin` for internal admin email/password login.
8+
- Add `/admin` as a lightweight landing route that redirects to `/admin/invitation-codes`.
89
- Configure Auth.js Credentials auth for internal users stored with `provider = 'internal'`.
910
- Add internal-only route protection for `/admin/*`.
1011
- Add `/admin/invitation-codes` with the required Milestone 5 flows:
1112
- list codes with purpose, label, status, usage count, expiry, max uses, and creator
12-
- create a code with purpose, label, optional expiry, and optional max uses
13+
- create a system-generated code with purpose, label, optional expiry, and optional max uses
1314
- activate/deactivate a code
1415
- show usage details or redemption history sufficient to explain inactive or exhausted state
1516
- Show the raw invitation code only once at creation time while persisting only the hash.
@@ -21,18 +22,26 @@ Add the internal-only auth path and admin surface needed to manage invitation co
2122
- Internal users should land on `/admin/invitation-codes` after successful sign-in.
2223
- Invitation-code management is future-ready through `purpose`, optional expiry, and optional max uses, but only `BETA_SIGNUP` redemption is implemented in Milestone 5.
2324
- The admin route contract can stay compact; usage details may be inline on `/admin/invitation-codes` instead of requiring a second admin detail route.
25+
- In Milestone 5, code creation UI can expose `purpose` as a fixed single-option control or a read-only value so implementation does not need a multi-purpose admin UX yet.
26+
- Admins can activate/deactivate codes, but editing a code’s raw value, expiry, or max uses after creation is out of scope in Milestone 5.
27+
- The list surface should show derived status, not just raw `isActive`, so exhausted and expired codes are immediately understandable.
2428

2529
## Acceptance Criteria
2630
- Internal admins can authenticate successfully with email/password through `/admin/signin`.
2731
- Invalid credentials fail cleanly without creating or mutating users.
2832
- `/admin/invitation-codes` lets internal admins create, activate/deactivate, and inspect codes and usage data.
2933
- Raw codes are only shown at creation time and are not persisted in plaintext.
3034
- Public users are blocked from `/admin/*`, and internal admins are redirected away from `/signup`.
35+
- Manual internal-admin bootstrap is documented with enough field-level detail to be reproducible through Supabase UI.
3136

3237
## Expected Touchpoints
3338
- `app/admin/signin/page.tsx`
39+
- `app/admin/page.tsx`
40+
- `app/admin/layout.tsx`
3441
- `app/admin/invitation-codes/page.tsx`
3542
- `app/api/auth/[...nextauth]/route.ts`
43+
- `components/admin/*`
3644
- `proxy.ts`
3745
- `lib/auth/*`
46+
- `lib/invitation-codes/*`
3847
- invitation-code repositories, server actions, and tests

docs/tasks/milestone-5/task-04-nickname-profile-public-shelf-sharing-and-club-invites.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Make nickname the default reader-facing identity, switch public shelf sharing fr
2929
- Invitation codes and private club invites are separate domains and should not be coupled in repository or UI behavior.
3030
- Inviting someone who has not completed signup is out of scope; the club-invite flow should fail clearly when the nickname does not map to a signed-up public user.
3131
- Duplicate pending club invites should be blocked per `clubId + invitedUserId`.
32+
- Invite acceptance UI and copy should stop referring to email entirely and should identify the invite target by nickname or a generic targeted-user message.
3233

3334
## Acceptance Criteria
3435
- `/me` clearly shows nickname as the primary Book by Book identity and surfaces the new profile fields.
@@ -37,6 +38,7 @@ Make nickname the default reader-facing identity, switch public shelf sharing fr
3738
- Club admins create private invites by nickname, not email.
3839
- Private-club invite creation fails with a clear error when the nickname does not exist, is incomplete, or already belongs to a club member.
3940
- Accepting a valid private-club invite works only for the targeted signed-in public user and remains idempotent.
41+
- Reader-facing invite pages and member/profile surfaces no longer rely on provider email as the visible identity fallback when nickname exists.
4042

4143
## Expected Touchpoints
4244
- `app/(protected)/me/page.tsx`

0 commit comments

Comments
 (0)