From 329be24f6de5f3b2a5b495cb654f013d746923af Mon Sep 17 00:00:00 2001 From: Mitch Downey Date: Mon, 22 Jun 2026 23:56:13 -0500 Subject: [PATCH] Implement membership rename across schema, ORM, APIs, and tooling - Added execution order documentation for the membership rename process. - Created summary and detailed plans for schema migrations, ORM entities, services, API usages, and E2E tooling. - Replaced `user_trust_settings` with `user_membership` in the database schema and updated related ORM entities and services. - Updated API middleware and management API to reflect the new membership structure. - Implemented local development seeding for the new membership model and adjusted E2E tests accordingly. - Ensured cumulative verification steps are documented for operator execution. This commit completes the transition from trust settings to a dedicated membership model, ensuring no legacy references remain. --- .../membership-rename/00-EXECUTION-ORDER.md | 45 +++++++++++++ .../completed/membership-rename/00-SUMMARY.md | 41 ++++++++++++ .../membership-rename/01-schema-migrations.md | 45 +++++++++++++ .../02-orm-entity-and-relations.md | 43 +++++++++++++ .../membership-rename/03-orm-services.md | 36 +++++++++++ .../04-api-and-management-usages.md | 42 +++++++++++++ .../05-seed-and-e2e-tooling.md | 52 +++++++++++++++ .../completed/membership-rename/COPY-PASTA.md | 53 ++++++++++++++++ apps/api/src/controllers/billingController.ts | 6 +- .../src/lib/billingReadModelSerialization.ts | 26 ++++---- apps/api/src/middleware/requireAuth.ts | 6 +- .../test/billing-renewal-orchestrator.test.ts | 22 +++---- .../src/controllers/usersController.ts | 10 +-- apps/management-web/src/proxy.ts | 38 ++++++++--- .../auth-stale-cookies-redirect-login.spec.ts | 59 ++++++++++++++++++ apps/web/next.config.mjs | 13 ++++ apps/web/src/app/(auth)/login/page.tsx | 10 +-- apps/web/src/context/AuthContext.tsx | 8 ++- apps/web/src/lib/api-client.ts | 27 +++++++- apps/web/src/proxy.ts | 47 +++++++++++--- docs/QUICK-START.md | 15 +++++ infra/docker/local/INFRA-DOCKER-LOCAL.md | 8 ++- .../0003a_app_linear_baseline.sql.gz | Bin 9640 -> 9525 bytes .../source/bootstrap/0008_seed_local_user.sql | 13 ++++ infra/k8s/base/ops/kustomization.yaml | 4 +- .../app/0003_user_membership.sql | 45 +++++++++++++ ...3_user_trust_and_entitlement_overrides.sql | 25 -------- ...illing_catalog_and_membership_renewal.sql} | 28 +-------- packages/orm/src/data-source.ts | 4 +- packages/orm/src/entities/User.ts | 6 +- ...UserTrustSettings.ts => UserMembership.ts} | 6 +- packages/orm/src/index.ts | 2 +- packages/orm/src/services/UserService.ts | 14 ++--- .../services/billingRenewalOrchestrator.ts | 8 +-- .../src/services/membershipPeriodExtension.ts | 28 ++++----- packages/orm/src/types/UserWithRelations.ts | 4 +- tools/web/seed-e2e.mjs | 2 +- 37 files changed, 691 insertions(+), 150 deletions(-) create mode 100644 .llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/membership-rename/00-SUMMARY.md create mode 100644 .llm/plans/completed/membership-rename/01-schema-migrations.md create mode 100644 .llm/plans/completed/membership-rename/02-orm-entity-and-relations.md create mode 100644 .llm/plans/completed/membership-rename/03-orm-services.md create mode 100644 .llm/plans/completed/membership-rename/04-api-and-management-usages.md create mode 100644 .llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md create mode 100644 .llm/plans/completed/membership-rename/COPY-PASTA.md create mode 100644 apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts create mode 100644 infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_membership.sql delete mode 100644 infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql rename infra/k8s/base/ops/source/database/linear-migrations/app/{0004_billing_catalog_and_trust_renewal.sql => 0004_billing_catalog_and_membership_renewal.sql} (76%) rename packages/orm/src/entities/{UserTrustSettings.ts => UserMembership.ts} (92%) diff --git a/.llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md b/.llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md new file mode 100644 index 00000000..ad13fe60 --- /dev/null +++ b/.llm/plans/completed/membership-rename/00-EXECUTION-ORDER.md @@ -0,0 +1,45 @@ +# Execution order — membership rename + +Phases are **sequential**. Do not start the next phase until the previous phase is complete and verified. + +## Phase 1 — Schema (blocking) + +- [01-schema-migrations.md](01-schema-migrations.md) + +## Phase 2 — ORM entity and relations + +- [02-orm-entity-and-relations.md](02-orm-entity-and-relations.md) + +Depends on Phase 1 (table name in `@Entity`). + +## Phase 3 — ORM services + +- [03-orm-services.md](03-orm-services.md) + +Depends on Phase 2 (`UserMembership` entity and `User.membership`). + +## Phase 4 — API and management-api + +- [04-api-and-management-usages.md](04-api-and-management-usages.md) + +Depends on Phase 3 (`upsertMembership`, relation name). + +## Phase 5 — Seed and E2E tooling + +- [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md) + +Depends on Phase 1 (`user_membership` table exists in schema). + +## After all phases + +Operator verification (cumulative): + +```bash +npm run build:packages +npm run lint +npm run test:unit +npm run test:e2e:api +make e2e_test_web_report_spec SPEC=e2e/auth-stale-cookies-redirect-login.spec.ts +``` + +For existing local Docker DB (table rename without full re-init), re-run `make local_db_init` or apply the one-off in [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md). diff --git a/.llm/plans/completed/membership-rename/00-SUMMARY.md b/.llm/plans/completed/membership-rename/00-SUMMARY.md new file mode 100644 index 00000000..b3e871c9 --- /dev/null +++ b/.llm/plans/completed/membership-rename/00-SUMMARY.md @@ -0,0 +1,41 @@ +# Membership rename plan set + +Hard-break rename of `user_trust_settings` → `user_membership` and `UserTrustSettings` → `UserMembership` across schema, ORM, APIs, seeds, and E2E tooling. No fallbacks or legacy names. + +## Recorded decisions + +| Decision | Choice | +|----------|--------| +| Table / entity | `user_membership` / `UserMembership` (single 1:1 table, inline `membership_tier`) | +| Podverse mirror | Naming domain only (membership); **not** the two-table split (`account_membership` + `account_membership_status`) | +| Scope | Table, entity, relation, and code usages only | +| Out of scope | `packages/helpers/src/trust/` (entitlement capability keys + membership constants); management-web `trustTier*` i18n keys | +| Migration style | Edit original `CREATE TABLE` in `0003`; fold `0004` ALTER columns into `0003`; rename migration files; regenerate `0003a` baseline | + +## Plan files + +| File | Topic | +|------|--------| +| [01-schema-migrations.md](01-schema-migrations.md) | Linear SQL, kustomization, baseline regen | +| [02-orm-entity-and-relations.md](02-orm-entity-and-relations.md) | Entity, User relation, exports | +| [03-orm-services.md](03-orm-services.md) | UserService, renewal orchestrator, period extension | +| [04-api-and-management-usages.md](04-api-and-management-usages.md) | requireAuth, billing, management users | +| [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md) | Local dev seed + E2E seed script | + +## Dependency map + +```mermaid +flowchart LR + schema[01_schema] --> ormEntity[02_orm_entity] + ormEntity --> ormServices[03_orm_services] + ormServices --> api[04_api_mgmt] + schema --> seed[05_seed] +``` + +## Integrates + +- Local dev login fix: `localdev` gets `user_membership` row (premium, long expiry) in [0008_seed_local_user.sql](../../../infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql). + +## Status + +Implemented on branch; operator must re-init local DB or run one-off insert for existing Docker volumes. diff --git a/.llm/plans/completed/membership-rename/01-schema-migrations.md b/.llm/plans/completed/membership-rename/01-schema-migrations.md new file mode 100644 index 00000000..bfe29555 --- /dev/null +++ b/.llm/plans/completed/membership-rename/01-schema-migrations.md @@ -0,0 +1,45 @@ +# 01 — Schema migrations + +## Scope + +Replace `user_trust_settings` with `user_membership` in the linear migration chain. Fold billing-renewal columns from `0004` into the `0003` `CREATE TABLE`. No ALTER-rename migration. + +## Steps + +1. **Replace** `infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql` with `0003_user_membership.sql`: + - `CREATE TABLE user_membership` with all membership + renewal columns + - Constraints `chk_user_membership_*` + - Trigger `set_updated_at_user_membership` + - Backfill `INSERT INTO user_membership ... SELECT ... FROM "user"` + - Indexes `idx_user_membership_next_renewal_attempt_at`, `idx_user_membership_renewal_retry_backoff_until` + +2. **Replace** `0004_billing_catalog_and_trust_renewal.sql` with `0004_billing_catalog_and_membership_renewal.sql`: + - Keep billing catalog tables and seeds + - Remove `ALTER TABLE user_trust_settings` and trust-named indexes + +3. **Update** [infra/k8s/base/ops/kustomization.yaml](infra/k8s/base/ops/kustomization.yaml) ConfigMap file list for the two new filenames. + +4. **Regenerate baseline** (operator or agent with Nix): + +```bash +bash scripts/database/generate-linear-baseline.sh +``` + +Commit `infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz`. + +## Key files + +- `infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_membership.sql` +- `infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql` +- `infra/k8s/base/ops/kustomization.yaml` +- `infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz` + +## Verification + +```bash +bash scripts/database/validate-linear-migrations.sh +bash scripts/database/verify-linear-baseline.sh +gunzip -c infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz | rg user_membership +``` + +Expect `user_membership` in baseline; no `user_trust_settings`. diff --git a/.llm/plans/completed/membership-rename/02-orm-entity-and-relations.md b/.llm/plans/completed/membership-rename/02-orm-entity-and-relations.md new file mode 100644 index 00000000..2ee8253f --- /dev/null +++ b/.llm/plans/completed/membership-rename/02-orm-entity-and-relations.md @@ -0,0 +1,43 @@ +# 02 — ORM entity and relations + +## Scope + +Rename TypeORM entity and `User` relation from trust settings to membership. + +## Steps + +1. **Delete** `packages/orm/src/entities/UserTrustSettings.ts`. + +2. **Add** `packages/orm/src/entities/UserMembership.ts`: + - `@Entity('user_membership')` + - Class `UserMembership` + - `@OneToOne('User', (u) => u.membership)` + +3. **Update** [packages/orm/src/entities/User.ts](packages/orm/src/entities/User.ts): + - Import `UserMembership` + - Relation property `membership!: UserMembership` + +4. **Update** [packages/orm/src/types/UserWithRelations.ts](packages/orm/src/types/UserWithRelations.ts): + - `membership: UserMembership | null` + +5. **Update** [packages/orm/src/data-source.ts](packages/orm/src/data-source.ts) entity list. + +6. **Update** [packages/orm/src/index.ts](packages/orm/src/index.ts): + - Export `UserMembership` (not `UserTrustSettings`) + +## Key files + +- `packages/orm/src/entities/UserMembership.ts` +- `packages/orm/src/entities/User.ts` +- `packages/orm/src/types/UserWithRelations.ts` +- `packages/orm/src/data-source.ts` +- `packages/orm/src/index.ts` + +## Verification + +```bash +npm run build -w @metaboost/orm +rg "UserTrustSettings|trustSettings" packages/orm/src +``` + +Expect no matches. diff --git a/.llm/plans/completed/membership-rename/03-orm-services.md b/.llm/plans/completed/membership-rename/03-orm-services.md new file mode 100644 index 00000000..64ba9ab0 --- /dev/null +++ b/.llm/plans/completed/membership-rename/03-orm-services.md @@ -0,0 +1,36 @@ +# 03 — ORM services + +## Scope + +Update services that read/write membership rows and raw SQL against the table. + +## Steps + +1. **UserService** ([packages/orm/src/services/UserService.ts](packages/orm/src/services/UserService.ts)): + - `USER_RELATIONS`: `'membership'` + - Repository `UserMembership` + - Rename `upsertTrustSettings` → `upsertMembership` + +2. **BillingRenewalOrchestratorService** ([packages/orm/src/services/billingRenewalOrchestrator.ts](packages/orm/src/services/billingRenewalOrchestrator.ts)): + - `FROM user_membership` in raw SQL + - `getRepository(UserMembership)` + +3. **MembershipPeriodExtensionService** ([packages/orm/src/services/membershipPeriodExtension.ts](packages/orm/src/services/membershipPeriodExtension.ts)): + - `getRepository(UserMembership)` + - Local variable `membership` (not `trust`) + - Error messages reference "membership" + +## Key files + +- `packages/orm/src/services/UserService.ts` +- `packages/orm/src/services/billingRenewalOrchestrator.ts` +- `packages/orm/src/services/membershipPeriodExtension.ts` + +## Verification + +```bash +npm run build -w @metaboost/orm +rg "user_trust_settings|UserTrustSettings|trustSettings" packages/orm/src +``` + +Expect no matches. diff --git a/.llm/plans/completed/membership-rename/04-api-and-management-usages.md b/.llm/plans/completed/membership-rename/04-api-and-management-usages.md new file mode 100644 index 00000000..84265106 --- /dev/null +++ b/.llm/plans/completed/membership-rename/04-api-and-management-usages.md @@ -0,0 +1,42 @@ +# 04 — API and management-api usages + +## Scope + +Update auth middleware, billing read model, management user CRUD, and integration tests. + +## Steps + +1. **requireAuth** ([apps/api/src/middleware/requireAuth.ts](apps/api/src/middleware/requireAuth.ts)): + - `Reflect.get(value, 'membership')` for `membershipExpiresAt` + +2. **billingController** ([apps/api/src/controllers/billingController.ts](apps/api/src/controllers/billingController.ts)): + - `user.membership` + +3. **billingReadModelSerialization** ([apps/api/src/lib/billingReadModelSerialization.ts](apps/api/src/lib/billingReadModelSerialization.ts)): + - `params.user.membership` + - Rename `premiumBillingCadenceFromTrust` → `premiumBillingCadenceFromMembership` + +4. **management usersController** ([apps/management-api/src/controllers/usersController.ts](apps/management-api/src/controllers/usersController.ts)): + - `user.membership` in `userToJson` + - `leftJoinAndSelect('user.membership', 'membership')` + - `UserService.upsertMembership` + +5. **Integration test** ([apps/api/src/test/billing-renewal-orchestrator.test.ts](apps/api/src/test/billing-renewal-orchestrator.test.ts)): + - SQL `user_membership` + - `refreshed?.membership` + +## Key files + +- `apps/api/src/middleware/requireAuth.ts` +- `apps/api/src/controllers/billingController.ts` +- `apps/api/src/lib/billingReadModelSerialization.ts` +- `apps/management-api/src/controllers/usersController.ts` +- `apps/api/src/test/billing-renewal-orchestrator.test.ts` + +## Verification + +```bash +npm run build -w apps/api +npm run build -w apps/management-api +npm run test:e2e:api +``` diff --git a/.llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md b/.llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md new file mode 100644 index 00000000..56407cc1 --- /dev/null +++ b/.llm/plans/completed/membership-rename/05-seed-and-e2e-tooling.md @@ -0,0 +1,52 @@ +# 05 — Seed and E2E tooling + +## Scope + +Local dev bootstrap user and Playwright E2E seed use `user_membership`. Integrates deferred login fix for `localdev@example.com`. + +## Steps + +1. **Local Docker bootstrap** [infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql](infra/k8s/base/db/source/bootstrap/0008_seed_local_user.sql): + - After `user_bio`, insert premium membership with long expiry: + +```sql +WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') +INSERT INTO user_membership (user_id, membership_tier, membership_expires_at, auto_renew) +SELECT id, 'premium', NOW() + INTERVAL '100 years', false FROM u +ON CONFLICT (user_id) DO NOTHING; +``` + + - Keep `user_terms_acceptance` insert for terms gate. + +2. **E2E seed** [tools/web/seed-e2e.mjs](tools/web/seed-e2e.mjs): + - `INSERT INTO user_membership` (replace `user_trust_settings`) + +## Operator — existing local DB (before re-init) + +After code deploy, if the volume still has `user_trust_settings`, **re-init** is the clean path: + +```bash +make local_db_init +``` + +Or one-off on fresh schema (`user_membership` table): + +```bash +docker exec -i metaboost_local_postgres psql -U metaboost_app_owner -d metaboost_app <<'SQL' +WITH u AS (SELECT user_id AS id FROM user_credentials WHERE email = 'localdev@example.com') +INSERT INTO user_membership (user_id, membership_tier, membership_expires_at, auto_renew) +SELECT id, 'premium', NOW() + INTERVAL '100 years', false FROM u +ON CONFLICT (user_id) DO NOTHING; +SQL +``` + +Clear cookies for `http://localhost:4002` and log in with `localdev@example.com` / `Test!1Aa`. + +## Verification + +```bash +make e2e_seed +make e2e_test_web_report_spec SPEC=e2e/auth-stale-cookies-redirect-login.spec.ts +``` + +Open `.artifacts/e2e-reports/latest/web/index.html`. diff --git a/.llm/plans/completed/membership-rename/COPY-PASTA.md b/.llm/plans/completed/membership-rename/COPY-PASTA.md new file mode 100644 index 00000000..484de7d7 --- /dev/null +++ b/.llm/plans/completed/membership-rename/COPY-PASTA.md @@ -0,0 +1,53 @@ +# COPY-PASTA — membership rename + +Execute prompts in order. Mark `[x]` when complete. Move completed numbered files to `.llm/plans/completed/membership-rename/` per plan-execution-completion-tracking. + +--- + +## Phase 1 — Schema + +- [x] **Prompt 1** — Implement [01-schema-migrations.md](01-schema-migrations.md): replace `0003`/`0004` migration files, update kustomization, regenerate `0003a` baseline. + +--- + +## Phase 2 — ORM entity + +- [x] **Prompt 2** — Implement [02-orm-entity-and-relations.md](02-orm-entity-and-relations.md): `UserMembership` entity, `User.membership`, exports. + +--- + +## Phase 3 — ORM services + +- [x] **Prompt 3** — Implement [03-orm-services.md](03-orm-services.md): `UserService.upsertMembership`, renewal orchestrator, period extension. + +--- + +## Phase 4 — API and management-api + +- [x] **Prompt 4** — Implement [04-api-and-management-usages.md](04-api-and-management-usages.md): requireAuth, billing, management users, integration tests. + +--- + +## Phase 5 — Seed and E2E + +- [x] **Prompt 5** — Implement [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md): `0008` local user membership seed, `tools/web/seed-e2e.mjs`. + +--- + +## Cumulative verification (operator) + +```bash +npm run build:packages +npm run lint +npm run test:unit +npm run test:e2e:api +make e2e_test_web_report_spec SPEC=e2e/auth-stale-cookies-redirect-login.spec.ts +``` + +For local dev login after rename: + +```bash +make local_db_init +``` + +Or apply the one-off in [05-seed-and-e2e-tooling.md](05-seed-and-e2e-tooling.md), then restart `npm run dev:all:watch`. diff --git a/apps/api/src/controllers/billingController.ts b/apps/api/src/controllers/billingController.ts index 1a438a41..c080fb2b 100644 --- a/apps/api/src/controllers/billingController.ts +++ b/apps/api/src/controllers/billingController.ts @@ -15,9 +15,9 @@ export async function membershipSummary(req: Request, res: Response): Promise { await appDataSourceReadWrite.query( ` - UPDATE user_trust_settings + UPDATE user_membership SET billing_cadence = $1, auto_renew_mode = $2, membership_expires_at = $3 @@ -65,8 +65,8 @@ describe('BillingRenewalOrchestratorService', () => { expect(summary.succeeded).toBeGreaterThanOrEqual(1); const refreshed = await UserService.findById(user.id); - expect(refreshed?.trustSettings?.membershipExpiresAt).not.toBeNull(); - expect(refreshed?.trustSettings?.membershipExpiresAt?.getTime() ?? 0).toBeGreaterThan( + expect(refreshed?.membership?.membershipExpiresAt).not.toBeNull(); + expect(refreshed?.membership?.membershipExpiresAt?.getTime() ?? 0).toBeGreaterThan( nearExpiry.getTime() ); @@ -101,7 +101,7 @@ describe('BillingRenewalOrchestratorService', () => { await appDataSourceReadWrite.query( ` - UPDATE user_trust_settings + UPDATE user_membership SET billing_cadence = $1, auto_renew_mode = $2, membership_expires_at = $3 @@ -117,7 +117,7 @@ describe('BillingRenewalOrchestratorService', () => { const eligible = (await appDataSourceReadWrite.query( ` SELECT user_id - FROM user_trust_settings + FROM user_membership WHERE user_id = $1 AND membership_tier = $2 AND auto_renew_mode = $3 @@ -149,7 +149,7 @@ describe('BillingRenewalOrchestratorService', () => { await appDataSourceReadWrite.query( ` - UPDATE user_trust_settings + UPDATE user_membership SET billing_cadence = $1, auto_renew_mode = $2, membership_expires_at = $3, @@ -175,10 +175,10 @@ describe('BillingRenewalOrchestratorService', () => { expect(summary.failed).toBeGreaterThanOrEqual(1); - const trustRows = (await appDataSourceReadWrite.query( + const membershipRows = (await appDataSourceReadWrite.query( ` SELECT renewal_retry_count, next_renewal_attempt_at, last_renewal_status - FROM user_trust_settings + FROM user_membership WHERE user_id = $1 `, [user.id] @@ -188,9 +188,9 @@ describe('BillingRenewalOrchestratorService', () => { last_renewal_status: string; }>; - expect(trustRows[0]?.renewal_retry_count).toBeGreaterThanOrEqual(1); - expect(trustRows[0]?.next_renewal_attempt_at).not.toBeNull(); - expect(trustRows[0]?.last_renewal_status).toBe('failed'); + expect(membershipRows[0]?.renewal_retry_count).toBeGreaterThanOrEqual(1); + expect(membershipRows[0]?.next_renewal_attempt_at).not.toBeNull(); + expect(membershipRows[0]?.last_renewal_status).toBe('failed'); const failEvents = (await appDataSourceReadWrite.query( ` diff --git a/apps/management-api/src/controllers/usersController.ts b/apps/management-api/src/controllers/usersController.ts index bd44ea99..9909c3dd 100644 --- a/apps/management-api/src/controllers/usersController.ts +++ b/apps/management-api/src/controllers/usersController.ts @@ -49,9 +49,9 @@ function userToJson(user: UserWithRelations): { email: user.credentials.email ?? null, username: user.credentials.username ?? null, displayName: user.bio?.displayName ?? null, - membershipTier: membershipTierFromStoredValue(user.trustSettings?.membershipTier), - membershipExpiresAt: user.trustSettings?.membershipExpiresAt?.toISOString() ?? null, - autoRenew: user.trustSettings?.autoRenew ?? false, + membershipTier: membershipTierFromStoredValue(user.membership?.membershipTier), + membershipExpiresAt: user.membership?.membershipExpiresAt?.toISOString() ?? null, + autoRenew: user.membership?.autoRenew ?? false, }; } @@ -100,7 +100,7 @@ export async function listUsers(req: Request, res: Response): Promise { .createQueryBuilder('user') .leftJoinAndSelect('user.credentials', 'credentials') .leftJoinAndSelect('user.bio', 'bio') - .leftJoinAndSelect('user.trustSettings', 'trustSettings'); + .leftJoinAndSelect('user.membership', 'membership'); if (sortBy === 'email') { qb.orderBy('credentials.email', sortOrder); @@ -306,7 +306,7 @@ export async function updateUser(req: Request, res: Response): Promise { body.membershipExpiresAt !== undefined || body.autoRenew !== undefined ) { - await UserService.upsertTrustSettings({ + await UserService.upsertMembership({ userId: id, membershipTier: body.membershipTier !== undefined diff --git a/apps/management-web/src/proxy.ts b/apps/management-web/src/proxy.ts index fb827a1b..c6ee3d17 100644 --- a/apps/management-web/src/proxy.ts +++ b/apps/management-web/src/proxy.ts @@ -3,6 +3,8 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getServerManagementApiBaseUrl } from './config/env'; +import { hasRuntimeConfig, setRuntimeConfig } from './config/runtime-config-store'; +import { fetchManagementWebRuntimeConfigFromSidecar } from './config/runtime-config.server'; import { parseManagementMeEnvelope, type ManagementSessionUser, @@ -36,6 +38,22 @@ function appendClearSessionCookies(res: NextResponse): void { res.headers.append('Set-Cookie', `${REFRESH_COOKIE_NAME}=; ${opts}`); } +async function ensureRuntimeConfigHydrated(): Promise { + if (hasRuntimeConfig()) { + return; + } + const runtimeConfigUrl = process.env.RUNTIME_CONFIG_URL?.trim(); + if (runtimeConfigUrl === undefined || runtimeConfigUrl === '') { + return; + } + try { + const runtimeConfig = await fetchManagementWebRuntimeConfigFromSidecar(); + setRuntimeConfig(runtimeConfig); + } catch { + // Fall through to process.env fallback in getRuntimeConfig(). + } +} + async function trySessionRestore(request: NextRequest): Promise<{ response: NextResponse; hasRestoredSession: boolean; @@ -83,18 +101,22 @@ async function trySessionRestore(request: NextRequest): Promise<{ } catch { // If me JSON cannot be parsed, continue with normal session handling. } + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } if (meRes.status !== 401) { + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } @@ -143,9 +165,11 @@ export async function proxy(request: NextRequest) { return nextWithoutInboundAuthUser(request); } - const { response, hasRestoredSession, sessionInvalidated } = await trySessionRestore(request); - const hasSession = - (request.cookies.has(SESSION_COOKIE_NAME) || hasRestoredSession) && !sessionInvalidated; + await ensureRuntimeConfigHydrated(); + + const { response, hasRestoredSession, sessionInvalidated, authUser } = + await trySessionRestore(request); + const hasSession = (authUser !== null || hasRestoredSession) && !sessionInvalidated; const isPublic = PUBLIC_PATHS.includes(pathname); // Protected route without validated session -> redirect to login diff --git a/apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts b/apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts new file mode 100644 index 00000000..494fed61 --- /dev/null +++ b/apps/web/e2e/auth-stale-cookies-redirect-login.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +import { getE2EApiV1BaseUrl } from './helpers/apiBase'; +import { capturePageLoad } from './helpers/stepScreenshots'; + +test.describe('Web stale session cookies', () => { + test('When bogus session cookies are sent, protected routes redirect to login and login does not bounce to dashboard.', async ({ + page, + context, + baseURL, + }, testInfo) => { + const origin = baseURL ?? 'http://localhost:4012'; + const { hostname } = new URL(origin); + await context.addCookies([ + { + name: 'api_session', + value: 'bogus-access-token-not-valid', + domain: hostname, + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + { + name: 'api_refresh', + value: 'bogus-refresh-token-not-valid', + domain: hostname, + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); + + await page.goto('/dashboard'); + await expect.poll(() => new URL(page.url()).pathname).toBe('/login'); + await capturePageLoad( + page, + testInfo, + 'User with invalid session cookies lands on login after attempting the dashboard.' + ); + await expect(page.getByRole('textbox', { name: /email/i })).toBeVisible(); + + await page.goto('/login'); + await expect.poll(() => new URL(page.url()).pathname).toBe('/login'); + await capturePageLoad( + page, + testInfo, + 'Login stays on login when session cookies are invalid and not treated as authenticated.' + ); + await expect(page.getByRole('textbox', { name: /email/i })).toBeVisible(); + }); + + test('Unauthenticated API /auth/me returns 401 (E2E uses direct API origin, not web /v1 rewrite).', async ({ + request, + }) => { + const response = await request.get(`${getE2EApiV1BaseUrl()}/auth/me`); + expect(response.status()).toBe(401); + expect(response.url()).toContain('/v1/auth/me'); + }); +}); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index e0e37193..793ed54c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -9,6 +9,19 @@ const withBundleAnalyzer = bundleAnalyzer({ const nextConfig = { transpilePackages: ['@metaboost/ui'], + async rewrites() { + if (process.env.NODE_ENV === 'production') { + return []; + } + const apiOrigin = process.env.WEB_DEV_API_PROXY_TARGET?.trim() || 'http://localhost:4000'; + const base = apiOrigin.replace(/\/$/, ''); + return [ + { + source: '/v1/:path*', + destination: `${base}/v1/:path*`, + }, + ]; + }, async headers() { if (process.env.NODE_ENV !== 'production') { return [ diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 6c4aa874..1ade5cfa 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import { useState } from 'react'; import { @@ -22,7 +22,6 @@ export default function LoginPage() { const tErrors = useTranslations('errors'); const tAuth = useTranslations('auth'); const { login } = useAuth(); - const router = useRouter(); const searchParams = useSearchParams(); const returnUrl = searchParams.get('returnUrl'); const showCheckEmailMessage = isTruthyQueryFlag(searchParams.get('checkEmail')); @@ -44,11 +43,14 @@ export default function LoginPage() { const result = await login(email, password); setLoading(false); if (result.ok) { - const target = + let target = returnUrl !== null && isSafeLoginReturnUrl(returnUrl, [ROUTES.LOGIN, ROUTES.SIGNUP]) ? returnUrl : ROUTES.DASHBOARD; - router.push(target); + if (result.mustAcceptTermsNow) { + target = ROUTES.TERMS_REQUIRED; + } + window.location.assign(target); } else if (result.rateLimit !== undefined) { setRateLimitRetrySeconds(result.rateLimit.retryAfterSeconds); setShowRateLimitModal(true); diff --git a/apps/web/src/context/AuthContext.tsx b/apps/web/src/context/AuthContext.tsx index 1029ba86..2c00e36b 100644 --- a/apps/web/src/context/AuthContext.tsx +++ b/apps/web/src/context/AuthContext.tsx @@ -62,7 +62,8 @@ export type AuthContextValue = { email: string, password: string ) => Promise< - { ok: true } | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } + | { ok: true; mustAcceptTermsNow: boolean } + | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } >; logout: () => Promise; setSession: (user: AuthUser) => void; @@ -182,7 +183,8 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { email: string, password: string ): Promise< - { ok: true } | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } + | { ok: true; mustAcceptTermsNow: boolean } + | { ok: false; message: string; rateLimit?: { retryAfterSeconds: number } } > => { const baseUrl = getApiBaseUrl(); const res = await webAuth.login(baseUrl, email, password); @@ -203,7 +205,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { if (parsed !== null) { setUser(mapAuthPayloadToUser(parsed)); } - return { ok: true }; + return { ok: true, mustAcceptTermsNow: parsed?.mustAcceptTermsNow === true }; }, [] ); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 4e701dce..d500c6e3 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -5,8 +5,29 @@ function getApiVersionPath(): string { return ver && ver.startsWith('/') ? ver : '/v1'; } +function resolvePublicApiOrigin(configuredPublicBaseUrl: string): string { + const trimmed = configuredPublicBaseUrl.replace(/\/$/, ''); + if (trimmed === '') { + if (typeof window !== 'undefined') { + return window.location.origin; + } + return ''; + } + if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') { + try { + const configured = new URL(trimmed); + if (configured.origin !== window.location.origin) { + return window.location.origin; + } + } catch { + // Fall through to configured URL when parsing fails. + } + } + return trimmed; +} + export function getApiBaseUrl(): string { - const base = getRuntimeConfig().env.NEXT_PUBLIC_API_PUBLIC_BASE_URL ?? ''; - const trimmed = base.replace(/\/$/, ''); - return trimmed + getApiVersionPath(); + const configured = getRuntimeConfig().env.NEXT_PUBLIC_API_PUBLIC_BASE_URL ?? ''; + const origin = resolvePublicApiOrigin(configured); + return origin.replace(/\/$/, '') + getApiVersionPath(); } diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 5ab9f69f..03333f58 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -5,6 +5,8 @@ import { NextResponse } from 'next/server'; import { isSafeRelativeAppPath } from '@metaboost/helpers'; import { getApiVersionPath, getAccountSignupMode, getServerApiBaseUrl } from './config/env'; +import { hasRuntimeConfig, setRuntimeConfig } from './config/runtime-config-store'; +import { fetchWebRuntimeConfigFromSidecar } from './config/runtime-config.server'; import { parseAuthEnvelope, type AuthUserPayload } from './lib/auth-user'; import { getWebAccountSignupModeCapabilities } from './lib/authMode'; import { isPublicPath, loginRoute, ROUTES } from './lib/routes'; @@ -36,6 +38,27 @@ function appendClearSessionCookies(res: NextResponse): void { res.headers.append('Set-Cookie', `${REFRESH_COOKIE_NAME}=; ${opts}`); } +async function ensureRuntimeConfigHydrated(): Promise { + if (hasRuntimeConfig()) { + return; + } + const runtimeConfigUrl = process.env.RUNTIME_CONFIG_URL?.trim(); + if (runtimeConfigUrl === undefined || runtimeConfigUrl === '') { + return; + } + try { + const runtimeConfig = await fetchWebRuntimeConfigFromSidecar(); + setRuntimeConfig(runtimeConfig); + } catch { + // Fall through to process.env fallback in getRuntimeConfig(). + } +} + +function isProxiedApiPath(pathname: string): boolean { + const versionPath = getApiVersionPath(); + return pathname === versionPath || pathname.startsWith(`${versionPath}/`); +} + async function trySessionRestore(request: NextRequest): Promise<{ response: NextResponse; hasRestoredSession: boolean; @@ -84,18 +107,22 @@ async function trySessionRestore(request: NextRequest): Promise<{ } catch { // If me JSON cannot be parsed, continue with normal session handling. } + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } if (meRes.status !== 401) { + const res = nextWithoutInboundAuthUser(request); + appendClearSessionCookies(res); return { - response: nextWithoutInboundAuthUser(request), + response: res, hasRestoredSession: false, - sessionInvalidated: false, + sessionInvalidated: true, authUser: null, }; } @@ -144,10 +171,16 @@ export async function proxy(request: NextRequest) { return nextWithoutInboundAuthUser(request); } + await ensureRuntimeConfigHydrated(); + + // Dev same-origin API proxy (next.config rewrites /v1/* → API); not an app route. + if (isProxiedApiPath(pathname)) { + return nextWithoutInboundAuthUser(request); + } + const { response, hasRestoredSession, sessionInvalidated, authUser } = await trySessionRestore(request); - const hasSession = - (request.cookies.has(SESSION_COOKIE_NAME) || hasRestoredSession) && !sessionInvalidated; + const hasSession = (authUser !== null || hasRestoredSession) && !sessionInvalidated; const isPublic = isPublicPath(pathname); const needsLatestTermsAcceptance = hasSession && authUser !== null && authUser.mustAcceptTermsNow === true; @@ -234,5 +267,5 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico|icon.svg).*)'], + matcher: ['/((?!_next/static|_next/image|favicon.ico|icon.svg|v1/).*)'], }; diff --git a/docs/QUICK-START.md b/docs/QUICK-START.md index 6dc55408..aa70f7a6 100644 --- a/docs/QUICK-START.md +++ b/docs/QUICK-START.md @@ -42,6 +42,21 @@ npm run dev:all - **Web:** http://localhost:4002 - **Web runtime-config sidecar:** http://localhost:4001 +### Local dev login + +After **`make local_db_init`**, the main web app has a seeded account: + +- **Email:** `localdev@example.com` +- **Password:** `Test!1Aa` + +Management-web requires a superuser from **`make local_management_superuser_create`** +(default username **`superuser`**, password **`Test!1Aa`**). + +If the browser loops between `/login` and `/dashboard` or logs **SecurityError** on history +API calls, clear site cookies for `http://localhost:4002` (remove `api_session` and +`api_refresh`) and use **`localhost`** consistently (not `127.0.0.1`). See +[INFRA-DOCKER-LOCAL.md](/infra/docker/local/INFRA-DOCKER-LOCAL.md) for infra details. + `make local_env_setup` seeds `apps/web/sidecar/.env` and `apps/management-web/sidecar/.env` from canonical template defaults plus overrides; `infra/config/local/*-sidecar.env` remains Docker Compose-only. See [LOCAL-ENV-OVERRIDES.md](development/LOCAL-ENV-OVERRIDES.md). diff --git a/infra/docker/local/INFRA-DOCKER-LOCAL.md b/infra/docker/local/INFRA-DOCKER-LOCAL.md index 4292ae7b..f170a460 100644 --- a/infra/docker/local/INFRA-DOCKER-LOCAL.md +++ b/infra/docker/local/INFRA-DOCKER-LOCAL.md @@ -32,7 +32,13 @@ To start only Postgres or Valkey (no management DB): - `docker compose -f infra/docker/local/docker-compose.yml --project-directory . up valkey` Default terms rows are created when **api** / **management-api** first start if `terms_version` is empty (not by init SQL). -The local-only dev account (**localdev@example.com** / **Test!1Aa**) is inserted by **`make local_db_init`** (seed file mounted at **`/opt/database/seed-scripts/local-dev-account.sql`**), not during initdb. +The local-only dev account (**localdev@example.com** / **Test!1Aa**) is inserted by **`make local_db_init`** (seed file mounted at **`/opt/database/seed-scripts/local-dev-account.sql`**), not during initdb. The seed includes terms acceptance for the bootstrap current version so host dev login can reach the dashboard without the terms gate. + +If the browser loops between `/login` and `/dashboard` after host dev (`npm run dev:all`), clear +`api_session` and `api_refresh` cookies for `http://localhost:4002` and use **`localhost`** +(not `127.0.0.1`) so cookie domain and CORS match local env. Management-web superuser: +**`make local_management_superuser_create`** (default **`superuser`** / **`Test!1Aa`**). + API/ORM use `DB_HOST=postgres` and `KEYVALDB_HOST=valkey` when running in Docker (Compose service hostname `valkey`). Kubernetes bases use cluster DNS `metaboost-db` / `metaboost-keyvaldb` for Postgres and Valkey Services. If Postgres previously failed during init (e.g. ordering bug) or you need a clean data directory, remove the volume and retry: **`make local_down_volumes`** or **`docker volume rm metaboost_postgres_data`**, then **`make local_infra_up`** and **`make local_db_init`**. diff --git a/infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz b/infra/k8s/base/db/source/bootstrap/0003a_app_linear_baseline.sql.gz index e83eea738f0b355b0ff25092f48a01c20c2e5a5c..61bf4819f0ff0ead8f5353cecf83aeca92d46359 100644 GIT binary patch literal 9525 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 Tw7b6i`NRJKx3WBF_8|cPySiX4 literal 9640 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 e00cd24_(}l)TlpRUcY TIMESTAMP '1970-01-01 00:00:00') +); + +CREATE TRIGGER set_updated_at_user_membership + BEFORE UPDATE ON user_membership + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_field(); + +INSERT INTO user_membership (user_id, membership_tier, auto_renew, auto_renew_mode) +SELECT u.id, 'trial', false, 'off' +FROM "user" AS u; + +CREATE INDEX idx_user_membership_next_renewal_attempt_at + ON user_membership(next_renewal_attempt_at) + WHERE next_renewal_attempt_at IS NOT NULL; + +CREATE INDEX idx_user_membership_renewal_retry_backoff_until + ON user_membership(renewal_retry_backoff_until) + WHERE renewal_retry_backoff_until IS NOT NULL; diff --git a/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql deleted file mode 100644 index 90d44c7f..00000000 --- a/infra/k8s/base/ops/source/database/linear-migrations/app/0003_user_trust_and_entitlement_overrides.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Forward-only: add membership lifecycle and trust foundation. --- Schema-only phase; no runtime gating behavior is enabled by this migration. --- Greenfield: omit legacy trust_tier_id (removed in earlier chains as a follow-up migration). - -CREATE TABLE user_trust_settings ( - user_id UUID PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE, - membership_tier VARCHAR(32) NOT NULL DEFAULT 'trial', - membership_expires_at TIMESTAMP NULL, - auto_renew BOOLEAN NOT NULL DEFAULT false, - created_at server_time_with_default NOT NULL, - updated_at server_time_with_default NOT NULL, - CONSTRAINT chk_user_trust_settings_membership_tier - CHECK (membership_tier IN ('trial', 'premium')), - CONSTRAINT chk_user_trust_settings_membership_expires_after_epoch - CHECK (membership_expires_at IS NULL OR membership_expires_at > TIMESTAMP '1970-01-01 00:00:00') -); - -CREATE TRIGGER set_updated_at_user_trust_settings - BEFORE UPDATE ON user_trust_settings - FOR EACH ROW - EXECUTE FUNCTION set_updated_at_field(); - -INSERT INTO user_trust_settings (user_id, membership_tier, auto_renew) -SELECT u.id, 'trial', false -FROM "user" AS u; diff --git a/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql similarity index 76% rename from infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql rename to infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql index 047283ae..dd44bbde 100644 --- a/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql +++ b/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_membership_renewal.sql @@ -1,4 +1,4 @@ --- Forward-only: billing product/price catalog, membership trial settings, renewal metadata on trust settings. +-- Forward-only: billing product/price catalog, membership trial settings. CREATE TABLE billing_product ( id SERIAL PRIMARY KEY, @@ -110,29 +110,3 @@ INSERT INTO product_membership_settings ( updated_at ) VALUES (1, 2678400, TIMESTAMP '2000-01-01 00:00:00', TIMESTAMP '2000-01-01 00:00:00'); - -ALTER TABLE user_trust_settings - ADD COLUMN billing_cadence TEXT CHECK ( - billing_cadence IS NULL OR billing_cadence IN ('monthly', 'annual') - ), - ADD COLUMN auto_renew_mode TEXT NOT NULL DEFAULT 'off' CHECK (auto_renew_mode IN ('off', 'on')), - ADD COLUMN next_renewal_attempt_at TIMESTAMP, - ADD COLUMN last_renewal_attempt_at TIMESTAMP, - ADD COLUMN last_renewal_status TEXT NOT NULL DEFAULT 'none' CHECK ( - last_renewal_status IN ('none', 'succeeded', 'failed') - ), - ADD COLUMN last_extension_idempotency_key VARCHAR(128), - ADD COLUMN last_renewal_idempotency_key VARCHAR(128), - ADD COLUMN renewal_retry_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN renewal_retry_backoff_until TIMESTAMP; - -UPDATE user_trust_settings -SET auto_renew_mode = CASE WHEN auto_renew THEN 'on' ELSE 'off' END; - -CREATE INDEX idx_user_trust_settings_next_renewal_attempt_at - ON user_trust_settings(next_renewal_attempt_at) - WHERE next_renewal_attempt_at IS NOT NULL; - -CREATE INDEX idx_user_trust_settings_renewal_retry_backoff_until - ON user_trust_settings(renewal_retry_backoff_until) - WHERE renewal_retry_backoff_until IS NOT NULL; diff --git a/packages/orm/src/data-source.ts b/packages/orm/src/data-source.ts index 1e333146..9a2ff344 100644 --- a/packages/orm/src/data-source.ts +++ b/packages/orm/src/data-source.ts @@ -30,8 +30,8 @@ import { TermsVersion, TermsVersionContent } from './entities/TermsVersion.js'; import { User } from './entities/User.js'; import { UserBio } from './entities/UserBio.js'; import { UserCredentials } from './entities/UserCredentials.js'; +import { UserMembership } from './entities/UserMembership.js'; import { UserTermsAcceptance } from './entities/UserTermsAcceptance.js'; -import { UserTrustSettings } from './entities/UserTrustSettings.js'; import { UserWebPushSubscription } from './entities/UserWebPushSubscription.js'; import { VerificationToken } from './entities/VerificationToken.js'; @@ -39,7 +39,7 @@ const ENTITIES = [ User, UserCredentials, UserBio, - UserTrustSettings, + UserMembership, BillingProduct, BillingPrice, BillingDomainEvent, diff --git a/packages/orm/src/entities/User.ts b/packages/orm/src/entities/User.ts index 46377819..dc913cb1 100644 --- a/packages/orm/src/entities/User.ts +++ b/packages/orm/src/entities/User.ts @@ -11,7 +11,7 @@ import { NANO_ID_V2_MAX_LENGTH } from '@metaboost/helpers'; import { UserBio } from './UserBio.js'; import { UserCredentials } from './UserCredentials.js'; -import { UserTrustSettings } from './UserTrustSettings.js'; +import { UserMembership } from './UserMembership.js'; @Entity('user') export class User { @@ -36,6 +36,6 @@ export class User { @OneToOne(() => UserBio, (b) => b.user) bio!: UserBio; - @OneToOne(() => UserTrustSettings, (trustSettings) => trustSettings.user) - trustSettings!: UserTrustSettings; + @OneToOne(() => UserMembership, (membership) => membership.user) + membership!: UserMembership; } diff --git a/packages/orm/src/entities/UserTrustSettings.ts b/packages/orm/src/entities/UserMembership.ts similarity index 92% rename from packages/orm/src/entities/UserTrustSettings.ts rename to packages/orm/src/entities/UserMembership.ts index e80ceb90..e8e3d96d 100644 --- a/packages/orm/src/entities/UserTrustSettings.ts +++ b/packages/orm/src/entities/UserMembership.ts @@ -2,8 +2,8 @@ import type { User } from './User.js'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; -@Entity('user_trust_settings') -export class UserTrustSettings { +@Entity('user_membership') +export class UserMembership { @PrimaryColumn('uuid', { name: 'user_id' }) userId!: string; @@ -49,7 +49,7 @@ export class UserTrustSettings { @Column({ name: 'updated_at', type: 'timestamp' }) updatedAt!: Date; - @OneToOne('User', (u: User) => u.trustSettings, { onDelete: 'CASCADE' }) + @OneToOne('User', (u: User) => u.membership, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User; } diff --git a/packages/orm/src/index.ts b/packages/orm/src/index.ts index 29102bbe..1658228b 100644 --- a/packages/orm/src/index.ts +++ b/packages/orm/src/index.ts @@ -1,6 +1,6 @@ export { appDataSource, appDataSourceRead, appDataSourceReadWrite } from './data-source.js'; export { User } from './entities/User.js'; -export { UserTrustSettings } from './entities/UserTrustSettings.js'; +export { UserMembership } from './entities/UserMembership.js'; export { UserCredentials } from './entities/UserCredentials.js'; export { UserBio } from './entities/UserBio.js'; export { diff --git a/packages/orm/src/services/UserService.ts b/packages/orm/src/services/UserService.ts index 30097e33..6a530778 100644 --- a/packages/orm/src/services/UserService.ts +++ b/packages/orm/src/services/UserService.ts @@ -11,9 +11,9 @@ import { appDataSourceRead, appDataSourceReadWrite } from '../data-source.js'; import { User } from '../entities/User.js'; import { UserBio } from '../entities/UserBio.js'; import { UserCredentials } from '../entities/UserCredentials.js'; -import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { UserMembership } from '../entities/UserMembership.js'; import { resolveDefaultMembershipExpiresAt } from '../lib/defaultMembershipExpiresAt.js'; -const USER_RELATIONS = ['credentials', 'bio', 'trustSettings'] as const; +const USER_RELATIONS = ['credentials', 'bio', 'membership'] as const; export class UserService { static async findById(id: string): Promise { @@ -89,7 +89,7 @@ export class UserService { const userRepo = qr.manager.getRepository(User); const credRepo = qr.manager.getRepository(UserCredentials); const bioRepo = qr.manager.getRepository(UserBio); - const trustSettingsRepo = qr.manager.getRepository(UserTrustSettings); + const membershipRepo = qr.manager.getRepository(UserMembership); const user = userRepo.create({ idText, @@ -117,14 +117,14 @@ export class UserService { premiumBillingCadence: data.premiumBillingCadence, }); const autoRenewVal = data.autoRenew ?? membershipTier === MembershipTier.Premium; - const trustSettings = trustSettingsRepo.create({ + const membership = membershipRepo.create({ userId: savedUser.id, membershipTier, membershipExpiresAt: data.membershipExpiresAt ?? defaultMembershipExpiresAt, autoRenew: autoRenewVal, autoRenewMode: autoRenewVal ? 'on' : 'off', }); - await trustSettingsRepo.save(trustSettings); + await membershipRepo.save(membership); await qr.commitTransaction(); const withRelations = await userRepo.findOne({ @@ -188,14 +188,14 @@ export class UserService { await repo.delete(userId); } - static async upsertTrustSettings(data: { + static async upsertMembership(data: { userId: string; membershipTier?: MembershipTier; membershipExpiresAt?: Date | null; autoRenew?: boolean; premiumBillingCadence?: PremiumBillingCadence; }): Promise { - const repo = appDataSourceReadWrite.getRepository(UserTrustSettings); + const repo = appDataSourceReadWrite.getRepository(UserMembership); const existing = await repo.findOne({ where: { userId: data.userId } }); const prevTier = diff --git a/packages/orm/src/services/billingRenewalOrchestrator.ts b/packages/orm/src/services/billingRenewalOrchestrator.ts index 1b36e67a..ba3c3a37 100644 --- a/packages/orm/src/services/billingRenewalOrchestrator.ts +++ b/packages/orm/src/services/billingRenewalOrchestrator.ts @@ -9,7 +9,7 @@ import { } from '@metaboost/helpers'; import { appDataSourceReadWrite } from '../data-source.js'; -import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { UserMembership } from '../entities/UserMembership.js'; import { BillingDomainEventLogService } from './billingDomainEventLog.js'; import { MembershipPeriodExtensionService } from './membershipPeriodExtension.js'; @@ -62,7 +62,7 @@ export class BillingRenewalOrchestratorService { last_renewal_idempotency_key AS "lastRenewalIdempotencyKey", last_renewal_status AS "lastRenewalStatus", renewal_retry_count AS "renewalRetryCount" - FROM user_trust_settings + FROM user_membership WHERE membership_tier = $1 AND auto_renew_mode = $2 AND billing_cadence IS NOT NULL @@ -146,7 +146,7 @@ export class BillingRenewalOrchestratorService { now, manager, }); - await manager.getRepository(UserTrustSettings).update( + await manager.getRepository(UserMembership).update( { userId: row.userId }, { lastRenewalAttemptAt: now, @@ -200,7 +200,7 @@ export class BillingRenewalOrchestratorService { retryAfterSeconds: number; }): Promise { const nextAttempt = new Date(params.now.getTime() + params.retryAfterSeconds * 1000); - const repo = params.manager.getRepository(UserTrustSettings); + const repo = params.manager.getRepository(UserMembership); await repo.update( { userId: params.userId }, { diff --git a/packages/orm/src/services/membershipPeriodExtension.ts b/packages/orm/src/services/membershipPeriodExtension.ts index 69b9281c..3c713d01 100644 --- a/packages/orm/src/services/membershipPeriodExtension.ts +++ b/packages/orm/src/services/membershipPeriodExtension.ts @@ -9,7 +9,7 @@ import { } from '@metaboost/helpers'; import { appDataSourceReadWrite } from '../data-source.js'; -import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { UserMembership } from '../entities/UserMembership.js'; import { BillingDomainEventLogService } from './billingDomainEventLog.js'; function eventTypeForExtensionReason( @@ -27,7 +27,7 @@ function eventTypeForExtensionReason( export class MembershipPeriodExtensionService { /** * Extends premium membership expiry using {@link extendMembershipPeriodByCadence} and records - * idempotency on trust settings. Delegates all calendar math to helpers. + * idempotency on membership. Delegates all calendar math to helpers. */ static async extendPremiumByCadence(params: { userId: string; @@ -44,30 +44,30 @@ export class MembershipPeriodExtensionService { applied: boolean; membershipExpiresAt: Date | null; }> => { - const repo = manager.getRepository(UserTrustSettings); - const trust = await repo.findOne({ where: { userId: params.userId } }); - if (trust === null) { + const repo = manager.getRepository(UserMembership); + const membership = await repo.findOne({ where: { userId: params.userId } }); + if (membership === null) { throw new Error( - 'MembershipPeriodExtensionService.extendPremiumByCadence: trust settings missing' + 'MembershipPeriodExtensionService.extendPremiumByCadence: membership missing' ); } - if (trust.membershipTier !== MembershipTier.Premium) { + if (membership.membershipTier !== MembershipTier.Premium) { throw new Error( 'MembershipPeriodExtensionService.extendPremiumByCadence: user is not premium' ); } - if (trust.lastExtensionIdempotencyKey === params.idempotencyKey) { - return { applied: false, membershipExpiresAt: trust.membershipExpiresAt }; + if (membership.lastExtensionIdempotencyKey === params.idempotencyKey) { + return { applied: false, membershipExpiresAt: membership.membershipExpiresAt }; } const newExpiry = extendMembershipPeriodByCadence({ - membershipExpiresAt: trust.membershipExpiresAt, + membershipExpiresAt: membership.membershipExpiresAt, cadence: params.cadence, now, }); - trust.membershipExpiresAt = newExpiry; - trust.billingCadence = params.cadence; - trust.lastExtensionIdempotencyKey = params.idempotencyKey; - await repo.save(trust); + membership.membershipExpiresAt = newExpiry; + membership.billingCadence = params.cadence; + membership.lastExtensionIdempotencyKey = params.idempotencyKey; + await repo.save(membership); const eventType = eventTypeForExtensionReason(params.reason); if (eventType !== null) { diff --git a/packages/orm/src/types/UserWithRelations.ts b/packages/orm/src/types/UserWithRelations.ts index 72a03f73..5062ae06 100644 --- a/packages/orm/src/types/UserWithRelations.ts +++ b/packages/orm/src/types/UserWithRelations.ts @@ -1,7 +1,7 @@ import type { User } from '../entities/User.js'; import type { UserBio } from '../entities/UserBio.js'; import type { UserCredentials } from '../entities/UserCredentials.js'; -import type { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import type { UserMembership } from '../entities/UserMembership.js'; /** * User with credentials and bio relations loaded (e.g. from UserService.findById/findByEmail/create). @@ -9,5 +9,5 @@ import type { UserTrustSettings } from '../entities/UserTrustSettings.js'; export type UserWithRelations = User & { credentials: UserCredentials; bio: UserBio | null; - trustSettings: UserTrustSettings | null; + membership: UserMembership | null; }; diff --git a/tools/web/seed-e2e.mjs b/tools/web/seed-e2e.mjs index 34ac2138..cee105b3 100644 --- a/tools/web/seed-e2e.mjs +++ b/tools/web/seed-e2e.mjs @@ -211,7 +211,7 @@ async function main() { E2E_DISPLAY_NAME9, ]); await client.query( - `INSERT INTO user_trust_settings ( + `INSERT INTO user_membership ( user_id, membership_tier, membership_expires_at,