From dd677d82b7bcbbc8312ee8ea4af6fd90c59de045 Mon Sep 17 00:00:00 2001 From: Mitch Downey Date: Wed, 6 May 2026 01:42:43 -0500 Subject: [PATCH 01/13] feat: introduce env-expiration naming convention and k8s skill documentation - Added a new rule for naming environment variables and exported constants related to expiration, ensuring they end with `_EXPIRATION` without additional suffixes. - Created documentation for the `env-expiration-naming` skill to guide developers on the correct naming conventions. - Introduced a new skill for Kustomize practices in Metaboost, detailing the composition of bases and overlays without sibling references. - Updated the skills index to include the new Kustomize skill, enhancing the overall documentation for development practices. --- .cursor/rules/env-expiration-naming.mdc | 13 + .cursor/skills/INDEX.md | 1 + .cursor/skills/env-expiration-naming/SKILL.md | 29 ++ .cursor/skills/k8s/SKILL.md | 33 ++ .../skills/linear-baseline-gz-sync/SKILL.md | 1 + .cursor/skills/linear-db-migrations/SKILL.md | 11 +- .cursor/skills/reusable-components/SKILL.md | 7 + .cursor/skills/use-form-component/SKILL.md | 5 + ...op-defaults-resolved-membership-part-01.md | 48 ++ .../env-expiration-naming-part-01.md | 28 ++ ...eenfield-only-migration-rewrite-part-01.md | 43 ++ .../helpers-subpath-export-trim-part-01.md | 28 ++ .../i18n-locale-sweep-part-01.md | 18 + ...near-migrations-greenfield-trim-part-01.md | 34 ++ ...bership-bootstrap-env-alignment-part-01.md | 39 ++ .../membership-product-metaboost-part-01.md | 163 +++++++ ...-kustomize-podverse-composition-part-01.md | 40 ++ .../mgmt-create-user-ux-part-01.md | 97 ++++ .../npm-overrides-cleanup-part-01.md | 29 ++ ...dor-agnostic-billing-foundation-part-01.md | 417 ++++++++++++++++++ .../metaboost-podverse-alignment/.gitkeep | 0 .../00-EXECUTION-ORDER.md | 30 ++ .../00-SUMMARY.md | 45 ++ .../01-domain-and-period-policy.md | 30 ++ .../02-pricing-catalog-and-schema.md | 36 ++ .../03-services-api-and-orchestration.md | 39 ++ .../04-membership-settings-bootstrap.md | 36 ++ .../05-management-pricing-governance.md | 38 ++ .../06-public-api-billing-read-models.md | 36 ++ .../07-tests-and-rollout-verification.md | 40 ++ .../COPY-PASTA.md | 20 + .../ROLLOUT-CHECKLIST.md | 47 ++ apps/api/.env.example | 7 +- apps/api/src/app.ts | 2 + apps/api/src/controllers/billingController.ts | 31 ++ apps/api/src/controllers/productController.ts | 20 + apps/api/src/index.ts | 5 +- .../src/lib/billingReadModelSerialization.ts | 67 +++ apps/api/src/lib/startup/validation.ts | 19 +- apps/api/src/openapi.ts | 127 ++++++ apps/api/src/routes/auth.ts | 6 + apps/api/src/routes/product.ts | 13 + .../test/billing-membership-summary.test.ts | 69 +++ .../test/billing-renewal-orchestrator.test.ts | 207 +++++++++ apps/api/src/test/helpers/setup.ts | 2 + .../test/membership-period-extension.test.ts | 76 ++++ apps/api/src/test/product-membership.test.ts | 37 ++ apps/management-api/.env.example | 5 + apps/management-api/src/app.ts | 8 + .../src/controllers/adminRolesController.ts | 8 + .../src/controllers/adminsController.ts | 4 + .../controllers/billingPricesController.ts | 111 +++++ .../src/controllers/usersController.ts | 10 +- apps/management-api/src/index.ts | 3 + .../src/lib/managementUserToJson.ts | 2 + .../src/lib/startup/validation.ts | 19 +- .../src/middleware/requireCrud.ts | 6 +- apps/management-api/src/openapi.ts | 379 +++++++++++++++- .../src/routes/billingPrices.ts | 45 ++ apps/management-api/src/routes/product.ts | 31 ++ apps/management-api/src/schemas/admins.ts | 6 + .../src/schemas/billingPrices.ts | 27 ++ apps/management-api/src/schemas/users.ts | 1 + .../src/test/billing-prices.test.ts | 189 ++++++++ apps/management-api/src/test/helpers/setup.ts | 2 + .../src/test/management-admin-roles.test.ts | 3 + .../src/test/management-api.test.ts | 17 + .../dashboard-super-admin-full-crud.spec.ts | 1 + .../e2e/helpers/advancedFixtures.ts | 1 + .../e2e/products-membership-billing.spec.ts | 30 ++ .../users-new-super-admin-full-crud.spec.ts | 6 +- apps/management-web/i18n/originals/en-US.json | 56 ++- apps/management-web/i18n/originals/es.json | 52 ++- apps/management-web/i18n/overrides/es.json | 50 +++ apps/management-web/sidecar/src/server.ts | 8 +- .../src/app/(main)/dashboard/page.tsx | 2 + .../membership/ProductsMembershipClient.tsx | 399 +++++++++++++++++ .../app/(main)/products/membership/page.tsx | 59 +++ .../src/components/admins/AdminForm.tsx | 24 +- .../src/components/admins/AdminRoleForm.tsx | 8 + .../src/components/users/UserForm.tsx | 169 ++++++- .../src/lib/createUserFormDefaults.ts | 41 ++ apps/management-web/src/lib/main-nav.ts | 12 +- apps/management-web/src/lib/routes.ts | 1 + .../src/types/management-api.ts | 1 + ...METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md | 2 + infra/config/env-templates/api.env.example | 8 +- .../env-templates/management-api.env.example | 8 +- infra/k8s/INFRA-K8S-BASE.md | 19 +- infra/k8s/INFRA-K8S.md | 4 +- infra/k8s/alpha/api/kustomization.yaml | 1 + .../alpha/management-api/kustomization.yaml | 1 + infra/k8s/alpha/ops/kustomization.yaml | 2 +- infra/k8s/base/api/deployment.yaml | 2 + infra/k8s/base/api/source/api.env | 5 +- .../0003a_app_linear_baseline.sql.gz | Bin 7597 -> 9197 bytes .../0003b_management_linear_baseline.sql.gz | Bin 2661 -> 2698 bytes infra/k8s/base/management-api/deployment.yaml | 2 + .../management-api/source/management-api.env | 3 +- .../source/management-web-sidecar.env | 1 + infra/k8s/base/ops/kustomization.yaml | 8 +- .../linear-migrations/app/0001_app_schema.sql | 13 - ..._settings_public_boost_display_minimum.sql | 15 - ...t_seed.sql => 0002_terms_default_seed.sql} | 37 +- ..._user_trust_and_entitlement_overrides.sql} | 11 +- ...0004_billing_catalog_and_trust_renewal.sql | 138 ++++++ .../app/0005_billing_domain_events.sql | 25 ++ .../app/0005_remove_user_trust_tier.sql | 8 - .../management/0001_management_schema.sql | 31 +- .../product-membership/kustomization.yaml | 11 + .../source/product-membership-settings.env | 5 + makefiles/local/Makefile.local.test.mk | 1 + package-lock.json | 203 ++------- package.json | 3 +- packages/helpers-requests/src/index.ts | 7 + .../src/management-web/adminRoles.ts | 4 + .../src/management-web/billingPrices.ts | 102 +++++ .../src/management-web/productMembership.ts | 18 + .../src/types/billing-read-model-types.ts | 6 + packages/helpers-requests/src/types/index.ts | 6 + .../src/types/management-admin-types.ts | 3 + .../src/types/management-user-types.ts | 4 + packages/helpers-requests/src/web/product.ts | 34 ++ packages/helpers/package.json | 7 + packages/helpers/src/index.ts | 36 ++ .../src/managementAdminRoles/constants.ts | 6 + .../helpers/src/membership/billingDomain.ts | 20 + .../src/membership/billingDomainEvents.ts | 12 + .../src/membership/billingReadModelTypes.ts | 46 ++ .../src/membership/billingRenewalAdapter.ts | 25 ++ .../membership/membershipPeriodPolicy.test.ts | 130 ++++++ .../src/membership/membershipPeriodPolicy.ts | 65 +++ .../src/membership/premiumBillingCadence.ts | 4 + .../productMembershipDefaultsFromEnv.ts | 66 +++ .../membership/resolvedProductMembership.ts | 7 + packages/helpers/src/time/addMonths.test.ts | 16 + packages/helpers/src/time/addMonths.ts | 22 +- .../src/time/toDateTimeLocalValue.test.ts | 29 ++ .../helpers/src/time/toDateTimeLocalValue.ts | 13 +- .../src/entities/AdminPermissions.ts | 3 + .../src/entities/ManagementAdminRole.ts | 3 + .../services/ManagementAdminRoleService.ts | 2 + .../src/services/ManagementUserService.ts | 6 + packages/orm/src/data-source.ts | 8 + .../orm/src/entities/BillingDomainEvent.ts | 24 + packages/orm/src/entities/BillingPrice.ts | 48 ++ packages/orm/src/entities/BillingProduct.ts | 28 ++ .../src/entities/ProductMembershipSettings.ts | 16 + .../orm/src/entities/UserTrustSettings.ts | 27 ++ packages/orm/src/index.ts | 24 + .../orm/src/lib/defaultMembershipExpiresAt.ts | 23 + .../orm/src/lib/freeTrialExpirationSeconds.ts | 25 ++ .../orm/src/lib/membershipDefaultMonths.ts | 29 -- packages/orm/src/services/UserService.ts | 62 +-- .../orm/src/services/billingDomainEventLog.ts | 46 ++ .../orm/src/services/billingPriceCatalog.ts | 164 +++++++ .../src/services/billingPriceGovernance.ts | 342 ++++++++++++++ .../services/billingRenewalOrchestrator.ts | 216 +++++++++ .../src/services/membershipPeriodExtension.ts | 107 +++++ .../src/services/productMembershipSettings.ts | 36 ++ .../form/FormActions/FormActions.module.scss | 1 + scripts/api/process-due-renewals.mjs | 31 ++ .../k8s/kustomize-build-metaboost-bases.sh | 2 +- tools/generate-data/src/management/seed.ts | 8 + tools/generate-data/src/types.ts | 1 + 165 files changed, 6098 insertions(+), 404 deletions(-) create mode 100644 .cursor/rules/env-expiration-naming.mdc create mode 100644 .cursor/skills/env-expiration-naming/SKILL.md create mode 100644 .cursor/skills/k8s/SKILL.md create mode 100644 .llm/history/active/drop-defaults-resolved-membership/drop-defaults-resolved-membership-part-01.md create mode 100644 .llm/history/active/env-expiration-naming/env-expiration-naming-part-01.md create mode 100644 .llm/history/active/greenfield-only-migration-rewrite/greenfield-only-migration-rewrite-part-01.md create mode 100644 .llm/history/active/helpers-subpath-export-trim/helpers-subpath-export-trim-part-01.md create mode 100644 .llm/history/active/i18n-locale-sweep/i18n-locale-sweep-part-01.md create mode 100644 .llm/history/active/linear-migrations-greenfield-trim/linear-migrations-greenfield-trim-part-01.md create mode 100644 .llm/history/active/membership-bootstrap-env-alignment/membership-bootstrap-env-alignment-part-01.md create mode 100644 .llm/history/active/membership-product-metaboost/membership-product-metaboost-part-01.md create mode 100644 .llm/history/active/metaboost-kustomize-podverse-composition/metaboost-kustomize-podverse-composition-part-01.md create mode 100644 .llm/history/active/mgmt-create-user-ux/mgmt-create-user-ux-part-01.md create mode 100644 .llm/history/active/npm-overrides-cleanup/npm-overrides-cleanup-part-01.md create mode 100644 .llm/history/active/vendor-agnostic-billing-foundation/vendor-agnostic-billing-foundation-part-01.md delete mode 100644 .llm/plans/active/metaboost-podverse-alignment/.gitkeep create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/00-SUMMARY.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/01-domain-and-period-policy.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/02-pricing-catalog-and-schema.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/03-services-api-and-orchestration.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/04-membership-settings-bootstrap.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/05-management-pricing-governance.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/06-public-api-billing-read-models.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/07-tests-and-rollout-verification.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/COPY-PASTA.md create mode 100644 .llm/plans/completed/vendor-agnostic-billing-foundation/ROLLOUT-CHECKLIST.md create mode 100644 apps/api/src/controllers/billingController.ts create mode 100644 apps/api/src/controllers/productController.ts create mode 100644 apps/api/src/lib/billingReadModelSerialization.ts create mode 100644 apps/api/src/routes/product.ts create mode 100644 apps/api/src/test/billing-membership-summary.test.ts create mode 100644 apps/api/src/test/billing-renewal-orchestrator.test.ts create mode 100644 apps/api/src/test/membership-period-extension.test.ts create mode 100644 apps/api/src/test/product-membership.test.ts create mode 100644 apps/management-api/src/controllers/billingPricesController.ts create mode 100644 apps/management-api/src/routes/billingPrices.ts create mode 100644 apps/management-api/src/routes/product.ts create mode 100644 apps/management-api/src/schemas/billingPrices.ts create mode 100644 apps/management-api/src/test/billing-prices.test.ts create mode 100644 apps/management-web/e2e/products-membership-billing.spec.ts create mode 100644 apps/management-web/src/app/(main)/products/membership/ProductsMembershipClient.tsx create mode 100644 apps/management-web/src/app/(main)/products/membership/page.tsx create mode 100644 apps/management-web/src/lib/createUserFormDefaults.ts delete mode 100644 infra/k8s/base/ops/source/database/linear-migrations/app/0002_bucket_settings_public_boost_display_minimum.sql rename infra/k8s/base/ops/source/database/linear-migrations/app/{0003_terms_default_seed.sql => 0002_terms_default_seed.sql} (80%) rename infra/k8s/base/ops/source/database/linear-migrations/app/{0004_user_trust_and_entitlement_overrides.sql => 0003_user_trust_and_entitlement_overrides.sql} (77%) create mode 100644 infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql create mode 100644 infra/k8s/base/ops/source/database/linear-migrations/app/0005_billing_domain_events.sql delete mode 100644 infra/k8s/base/ops/source/database/linear-migrations/app/0005_remove_user_trust_tier.sql create mode 100644 infra/k8s/base/product-membership/kustomization.yaml create mode 100644 infra/k8s/base/product-membership/source/product-membership-settings.env create mode 100644 packages/helpers-requests/src/management-web/billingPrices.ts create mode 100644 packages/helpers-requests/src/management-web/productMembership.ts create mode 100644 packages/helpers-requests/src/types/billing-read-model-types.ts create mode 100644 packages/helpers-requests/src/web/product.ts create mode 100644 packages/helpers/src/membership/billingDomain.ts create mode 100644 packages/helpers/src/membership/billingDomainEvents.ts create mode 100644 packages/helpers/src/membership/billingReadModelTypes.ts create mode 100644 packages/helpers/src/membership/billingRenewalAdapter.ts create mode 100644 packages/helpers/src/membership/membershipPeriodPolicy.test.ts create mode 100644 packages/helpers/src/membership/membershipPeriodPolicy.ts create mode 100644 packages/helpers/src/membership/premiumBillingCadence.ts create mode 100644 packages/helpers/src/membership/productMembershipDefaultsFromEnv.ts create mode 100644 packages/helpers/src/membership/resolvedProductMembership.ts create mode 100644 packages/helpers/src/time/toDateTimeLocalValue.test.ts create mode 100644 packages/orm/src/entities/BillingDomainEvent.ts create mode 100644 packages/orm/src/entities/BillingPrice.ts create mode 100644 packages/orm/src/entities/BillingProduct.ts create mode 100644 packages/orm/src/entities/ProductMembershipSettings.ts create mode 100644 packages/orm/src/lib/defaultMembershipExpiresAt.ts create mode 100644 packages/orm/src/lib/freeTrialExpirationSeconds.ts delete mode 100644 packages/orm/src/lib/membershipDefaultMonths.ts create mode 100644 packages/orm/src/services/billingDomainEventLog.ts create mode 100644 packages/orm/src/services/billingPriceCatalog.ts create mode 100644 packages/orm/src/services/billingPriceGovernance.ts create mode 100644 packages/orm/src/services/billingRenewalOrchestrator.ts create mode 100644 packages/orm/src/services/membershipPeriodExtension.ts create mode 100644 packages/orm/src/services/productMembershipSettings.ts create mode 100644 scripts/api/process-due-renewals.mjs diff --git a/.cursor/rules/env-expiration-naming.mdc b/.cursor/rules/env-expiration-naming.mdc new file mode 100644 index 00000000..e962090f --- /dev/null +++ b/.cursor/rules/env-expiration-naming.mdc @@ -0,0 +1,13 @@ +--- +description: Names ending in _EXPIRATION (env + exported constants) use seconds only; no _SECONDS suffix on the symbol. +globs: + - "**/*.env*" + - "**/parseEnv*.ts" + - "**/*Expiration*.ts" + - "**/productMembershipDefaults*.ts" +alwaysApply: false +--- + +# Expiration naming + +Follow [`env-expiration-naming`](.cursor/skills/env-expiration-naming/SKILL.md): env keys and exported constants that denote expiration durations end with `_EXPIRATION`; values are seconds — do not append `_SECONDS` to those names. diff --git a/.cursor/skills/INDEX.md b/.cursor/skills/INDEX.md index 0870e8c0..e753c396 100644 --- a/.cursor/skills/INDEX.md +++ b/.cursor/skills/INDEX.md @@ -19,6 +19,7 @@ Skills are invoked when the task matches their "when to use" scope. **For task | e2e-page-tests | Layout or behavior changes in web/management-web | | response-ending-make-verify | Ending implementation responses with E2E make commands | | i18n | Translation keys, locales, generating translations | +| k8s | `infra/k8s/` Kustomize bases vs overlays; shared bundles only in overlays with matching `?ref=` | | path-casing-imports | Relative imports or CI module-not-found (casing) | Other skills in this directory cover forms, tables, DB/ORM, management-web CRUD, documentation, and style. Rules (`.cursor/rules/*.mdc`) apply automatically by glob or always; see AGENTS.md. diff --git a/.cursor/skills/env-expiration-naming/SKILL.md b/.cursor/skills/env-expiration-naming/SKILL.md new file mode 100644 index 00000000..30e37311 --- /dev/null +++ b/.cursor/skills/env-expiration-naming/SKILL.md @@ -0,0 +1,29 @@ +--- +name: env-expiration-naming +description: >- + Naming for env keys and TypeScript symbols that represent time-until-expiration or duration until + expiry. Use when adding or renaming MEMBERSHIP_FREE_TRIAL_EXPIRATION, JWT expiration env, or similar. +--- + +# Env and code: `*_EXPIRATION` naming + +## Rule + +- **Env keys** and **exported JS/TS constant names** that represent an expiration-related duration end with **`_EXPIRATION`** — not `_EXPIRATION_SECONDS`, `_SECONDS`, or other unit suffixes after `_EXPIRATION`. +- For these names, **values are always in seconds**. Do not encode “seconds” again in the identifier; `_EXPIRATION` implies the unit. + +## Do + +- Env (membership bootstrap): `MEMBERSHIP_FREE_TRIAL_EXPIRATION`, plus premium list prices + `MEMBERSHIP_PREMIUM_COST_MONTHLY` / `MEMBERSHIP_PREMIUM_COST_ANNUALLY` (USD; non-expiration keys). +- Env (JWT / tokens): management JWT expiration env vars documented with seconds. +- Constants: `DEFAULT_FREE_TRIAL_EXPIRATION` (number of seconds when unset). + +## Don’t + +- `DEFAULT_FREE_TRIAL_EXPIRATION_SECONDS` +- Duplicating the unit in the symbol name when `_EXPIRATION` already denotes “seconds duration.” + +## Related + +- `@metaboost/helpers` `membership/productMembershipDefaultsFromEnv.ts` uses `DEFAULT_FREE_TRIAL_EXPIRATION`. diff --git a/.cursor/skills/k8s/SKILL.md b/.cursor/skills/k8s/SKILL.md new file mode 100644 index 00000000..bb48794a --- /dev/null +++ b/.cursor/skills/k8s/SKILL.md @@ -0,0 +1,33 @@ +--- +name: metaboost-k8s-kustomize +description: Kustomize bases vs overlays under infra/k8s — no sibling ../ bases in component kustomizations; shared bundles (e.g. product-membership) composed in overlays with matching remote ?ref=. Use when editing infra/k8s kustomization.yaml, alpha overlays, or GitOps remote resources. +--- + +# Metaboost Kubernetes / Kustomize + +## When to use + +- Editing `infra/k8s/base/**/kustomization.yaml` or `infra/k8s/alpha/**/kustomization.yaml` +- Adding shared ConfigMaps or cross-cutting env that multiple workloads consume +- Aligning in-repo alpha with external GitOps overlay patterns + +## Bases stay self-contained (Podverse-style) + +- Do **not** add `resources: - ../other-base` inside `infra/k8s/base//` to pull a sibling directory. That couples bases and breaks clean remote-Git consumption. +- **`base/product-membership/`** generates `metaboost-product-membership-config`. **api** and **management-api** Deployments reference it, but their **`base//kustomization.yaml`** lists only that component’s YAML + its own `configMapGenerator` — not `product-membership`. + +## Compose shared bundles in overlays + +- In-repo: **`infra/k8s/alpha/api/`** and **`infra/k8s/alpha/management-api/`** list **two** remote `resources:` entries — `base/` and **`base/product-membership`** — with the **same** `?ref=` tag as the primary base. +- External GitOps: same pattern in `apps/metaboost-alpha/api/` and `.../management-api/` (see [METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md](../../../docs/development/release/METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md)). + +## References + +- [infra/k8s/INFRA-K8S.md](../../../infra/k8s/INFRA-K8S.md) — layout, revision policy, cross-component bases +- [infra/k8s/INFRA-K8S-BASE.md](../../../infra/k8s/INFRA-K8S-BASE.md) — per-directory table +- [docs/development/k8s/REMOTE-K8S-GITOPS.md](../../../docs/development/k8s/REMOTE-K8S-GITOPS.md) — remote URLs and safety workflow +- **argocd-gitops-push** skill — push reminder when changing `infra/k8s/` + +## Build flags + +Use `kubectl kustomize ... --load-restrictor LoadRestrictionsNone` when building overlays that reference paths outside the current folder (see INFRA-K8S-BASE.md). diff --git a/.cursor/skills/linear-baseline-gz-sync/SKILL.md b/.cursor/skills/linear-baseline-gz-sync/SKILL.md index bd4cbb48..edca1473 100644 --- a/.cursor/skills/linear-baseline-gz-sync/SKILL.md +++ b/.cursor/skills/linear-baseline-gz-sync/SKILL.md @@ -34,3 +34,4 @@ Keep committed bootstrap baseline archives aligned with the canonical forward-on - Do not hand-edit `.sql.gz` archives. - Generated baseline artifacts are source-controlled and must be updated in the same PR as migration changes. +- Migration SQL should follow **greenfield-only** authoring (ordered chain, minimal `IF EXISTS` / idempotent seed guards); see [linear-db-migrations](../linear-db-migrations/SKILL.md). diff --git a/.cursor/skills/linear-db-migrations/SKILL.md b/.cursor/skills/linear-db-migrations/SKILL.md index e3bff344..bb8c9ac0 100644 --- a/.cursor/skills/linear-db-migrations/SKILL.md +++ b/.cursor/skills/linear-db-migrations/SKILL.md @@ -11,6 +11,15 @@ version: 1.0.0 - Adding or changing files under `infra/k8s/base/db/source/`, `infra/k8s/base/ops/`, or `scripts/database/`. - Wiring DB credentials in Compose, Kubernetes manifests, or env templates/examples. +## Greenfield-only SQL authoring + +Treat each chain as **ordered fresh applies**: migration `NNNN` may assume schema and data from all earlier files in that chain only. + +- Prefer plain `CREATE`, `ALTER … ADD`, `DROP`, `INSERT` when existence/absence is guaranteed by predecessors. +- Avoid `CREATE … IF NOT EXISTS`, `DROP … IF EXISTS`, seed `INSERT … ON CONFLICT`, and `INSERT … WHERE NOT EXISTS` unless an earlier migration leaves real ambiguity. +- Avoid `DO $$` blocks that probe `information_schema` solely to support legacy shapes removed from the canonical chain. +- The migration runner **creates** `linear_migration_history` before applying files; **do not** add another `CREATE TABLE linear_migration_history` inside `0001_*` (or later) that would duplicate it. + ## Single source of truth - **Canonical forward-only SQL:** `infra/k8s/base/ops/source/database/linear-migrations/app/` and `infra/k8s/base/ops/source/database/linear-migrations/management/` (ordered `0001_*.sql`, ...). @@ -30,7 +39,7 @@ version: 1.0.0 - `bash scripts/database/generate-linear-baseline.sh` - Verify generated artifacts are committed and up to date: - `bash scripts/database/verify-linear-baseline.sh` -- The migration runner **creates** the `linear_migration_history` table if missing; do not rely on a dedicated SQL file for that. +- Runner bootstraps `linear_migration_history` before numbered `.sql` files run (see **Greenfield-only SQL authoring** above). ## Ops bundle (cache busting) diff --git a/.cursor/skills/reusable-components/SKILL.md b/.cursor/skills/reusable-components/SKILL.md index b0d5a626..5aacc87e 100644 --- a/.cursor/skills/reusable-components/SKILL.md +++ b/.cursor/skills/reusable-components/SKILL.md @@ -19,6 +19,13 @@ version: 1.0.0 - **Auth form layout:** Use a shared `AuthFormLayout` (or similar) component with its own `.module.scss` instead of auth-specific classes in `globals.scss`. - **Repeated page structure:** If multiple pages share a wrapper (e.g. centered card, same padding), create a layout component that accepts `children` and owns the structure and styles. +## Management-web forms (Metaboost) + +- **`FormActions`** from `@metaboost/ui` should keep **Cancel then Submit** in the DOM and rely on + the component’s **right-aligned** footer (see **use-form-component**). +- Prefer **`Select`** from `@metaboost/ui` over raw ` + ) : null} + + + + + + + + {t('columnWhen')} + {t('columnCurrency')} + {t('columnCadence')} + {t('columnAmount')} + {t('columnReason')} + {t('columnActor')} + + + + {audit.map((a) => { + const displayAmount = + a.newAmountCents !== null + ? formatMajorUnits(locale, a.currencyCode ?? 'USD', a.newAmountCents) + : a.previousAmountCents !== null + ? formatMajorUnits(locale, a.currencyCode ?? 'USD', a.previousAmountCents) + : '—'; + return ( + + {formatDateTimeReadable(locale, a.createdAt)} + {a.currencyCode ?? t('unknownCurrency')} + + {a.billingCadence === null + ? '—' + : a.billingCadence === 'monthly' + ? t('cadenceMonthly') + : t('cadenceAnnual')} + + {displayAmount} + {a.changeReason ?? '—'} + {shortId(a.changedByManagementUserId)} + + ); + })} + +
+
+
+ + {crudFlags.create ? ( + + + + + + + + + + + + + + ) : null} + + ); +} diff --git a/apps/management-web/src/app/(main)/products/membership/page.tsx b/apps/management-web/src/app/(main)/products/membership/page.tsx new file mode 100644 index 00000000..94b9e6a1 --- /dev/null +++ b/apps/management-web/src/app/(main)/products/membership/page.tsx @@ -0,0 +1,59 @@ +import type { BreadcrumbItem } from '@metaboost/ui'; + +import { getTranslations } from 'next-intl/server'; +import { redirect } from 'next/navigation'; + +import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; + +import { getCrudFlags, hasReadPermission } from '../../../../lib/main-nav'; +import { ROUTES } from '../../../../lib/routes'; +import { getServerUser } from '../../../../lib/server-auth'; +import { ProductsMembershipClient } from './ProductsMembershipClient'; + +function BreadcrumbLink({ + href, + children, + className, +}: { + href: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +export default async function ProductsMembershipPage() { + const user = await getServerUser(); + + if (user === null) { + redirect(ROUTES.LOGIN); + } + + const canRead = + user.isSuperAdmin === true || hasReadPermission(user.permissions, 'billingPricesCrud'); + if (!canRead) { + redirect(ROUTES.DASHBOARD); + } + + const crud = getCrudFlags(user.isSuperAdmin === true, user.permissions, 'billingPricesCrud'); + const tCommon = await getTranslations('common'); + const t = await getTranslations('billingGovernance'); + const breadcrumbItems: BreadcrumbItem[] = [ + { label: tCommon('dashboard'), href: ROUTES.DASHBOARD }, + { label: t('pageTitle'), href: undefined }, + ]; + + return ( + } + title={t('pageTitle')} + contentMaxWidth="readable" + > + + + ); +} diff --git a/apps/management-web/src/components/admins/AdminForm.tsx b/apps/management-web/src/components/admins/AdminForm.tsx index e5f818de..1be398fb 100644 --- a/apps/management-web/src/components/admins/AdminForm.tsx +++ b/apps/management-web/src/components/admins/AdminForm.tsx @@ -40,6 +40,7 @@ type ManagementAdminRoleOption = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; }; @@ -55,6 +56,7 @@ export type AdminFormInitialValues = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; } | null; }; @@ -106,6 +108,7 @@ function roleToOption( bucketsCrud: role.bucketsCrud, bucketMessagesCrud: role.bucketMessagesCrud, bucketAdminsCrud: role.bucketAdminsCrud, + billingPricesCrud: role.billingPricesCrud, eventVisibility: role.eventVisibility, }; } @@ -116,7 +119,8 @@ function rolePermissionScore(role: ManagementAdminRoleOption): number { role.usersCrud + role.bucketsCrud + role.bucketMessagesCrud + - role.bucketAdminsCrud; + role.bucketAdminsCrud + + role.billingPricesCrud; const eventScore = role.eventVisibility === 'all' ? 2 : role.eventVisibility === 'all_admins' ? 1 : 0; return crudScore * 10 + eventScore; @@ -157,6 +161,7 @@ export function AdminForm({ const defaultBucketsCrud = defaultPerms?.bucketsCrud ?? 0; const defaultBucketMessagesCrud = defaultPerms?.bucketMessagesCrud ?? 0; const defaultBucketAdminsCrud = defaultPerms?.bucketAdminsCrud ?? 0; + const defaultBillingPricesCrud = defaultPerms?.billingPricesCrud ?? 0; const [adminsCrudFlags, setAdminsCrudFlags] = useState( bitmaskToFlags(defaultAdminsCrud) ); @@ -170,6 +175,9 @@ export function AdminForm({ const [bucketAdminsCrudFlags, setBucketAdminsCrudFlags] = useState( bitmaskToFlags(defaultBucketAdminsCrud) ); + const [billingPricesCrudFlags, setBillingPricesCrudFlags] = useState( + bitmaskToFlags(defaultBillingPricesCrud) + ); const [eventVisibility, setEventVisibility] = useState( defaultPerms?.eventVisibility ?? 'all_admins' ); @@ -227,6 +235,7 @@ export function AdminForm({ setBucketsCrudFlags(bitmaskToFlags(selectedRole.bucketsCrud)); setBucketMessagesCrudFlags(bitmaskToFlags(selectedRole.bucketMessagesCrud)); setBucketAdminsCrudFlags(bitmaskToFlags(selectedRole.bucketAdminsCrud)); + setBillingPricesCrudFlags(bitmaskToFlags(selectedRole.billingPricesCrud)); setEventVisibility(selectedRole.eventVisibility); }, [selectedRole]); @@ -303,6 +312,10 @@ export function AdminForm({ setBucketAdminsCrudFlags(next); }; + const handleBillingPricesCrudChange = (next: CrudFlags) => { + setBillingPricesCrudFlags(next); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -351,6 +364,7 @@ export function AdminForm({ bucketsCrud: flagsToBitmask(bucketsCrudFlags), bucketMessagesCrud: flagsToBitmask(bucketMessagesCrudFlags), bucketAdminsCrud: flagsToBitmask(bucketAdminsCrudFlags), + billingPricesCrud: flagsToBitmask(billingPricesCrudFlags), eventVisibility, }; const res = await managementWebAdmins.createAdmin(apiBaseUrl, body); @@ -386,6 +400,7 @@ export function AdminForm({ body.bucketsCrud = flagsToBitmask(bucketsCrudFlags); body.bucketMessagesCrud = flagsToBitmask(bucketMessagesCrudFlags); body.bucketAdminsCrud = flagsToBitmask(bucketAdminsCrudFlags); + body.billingPricesCrud = flagsToBitmask(billingPricesCrudFlags); body.eventVisibility = eventVisibility; } } @@ -502,6 +517,13 @@ export function AdminForm({ onChange={handleBucketMessagesCrudChange} disabled /> + ('annual'); + const [createFormHydrated, setCreateFormHydrated] = useState(false); + const skipTierCadenceEffect = useRef(true); + const [resolvedProductMembership, setResolvedProductMembership] = + useState(null); const [initialBucketAdminIds, setInitialBucketAdminIds] = useState([]); const [buckets, setBuckets] = useState([]); @@ -90,6 +114,81 @@ export function UserForm({ mode, userId, initialValues, activeEditTab }: UserFor }; }, [mode, apiBaseUrl]); + useEffect(() => { + if (mode !== 'create') { + return; + } + let cancelled = false; + void (async () => { + const res = await managementWebProductMembership.getResolvedProductMembership(apiBaseUrl); + if (cancelled) { + return; + } + if (res.ok && res.data !== undefined) { + setResolvedProductMembership(res.data.data); + } else { + setResolvedProductMembership(fallbackProductMembershipFromEnv()); + } + })(); + return () => { + cancelled = true; + }; + }, [mode, apiBaseUrl]); + + useEffect(() => { + if (mode !== 'create' || resolvedProductMembership === null) { + return; + } + const tierOnMount = initialValues?.membershipTier ?? MembershipTier.Trial; + const cadRaw = window.localStorage.getItem(STORAGE_CADENCE_KEY); + const cad: PremiumBillingCadence = cadRaw === 'monthly' ? 'monthly' : 'annual'; + setPremiumBillingCadence(cad); + const savedExp = window.localStorage.getItem(STORAGE_EXPIRY_KEY); + if (savedExp !== null && savedExp.includes('T')) { + setMembershipExpiresAt(savedExp); + } else { + setMembershipExpiresAt( + computeDefaultMembershipExpiresAtInput({ + membershipTier: tierOnMount, + premiumBillingCadence: cad, + trialExpirationSeconds: resolvedProductMembership.freeTrialExpirationSeconds, + }) + ); + } + setCreateFormHydrated(true); + }, [mode, initialValues?.membershipTier, resolvedProductMembership]); + + useEffect(() => { + if (mode !== 'create' || !createFormHydrated || resolvedProductMembership === null) { + return; + } + if (skipTierCadenceEffect.current) { + skipTierCadenceEffect.current = false; + return; + } + setMembershipExpiresAt( + computeDefaultMembershipExpiresAtInput({ + membershipTier, + premiumBillingCadence, + trialExpirationSeconds: resolvedProductMembership.freeTrialExpirationSeconds, + }) + ); + }, [membershipTier, premiumBillingCadence, createFormHydrated, mode, resolvedProductMembership]); + + useEffect(() => { + if (mode !== 'create' || !createFormHydrated || membershipExpiresAt.trim() === '') { + return; + } + window.localStorage.setItem(STORAGE_EXPIRY_KEY, membershipExpiresAt); + }, [membershipExpiresAt, createFormHydrated, mode]); + + useEffect(() => { + if (mode !== 'create' || !createFormHydrated) { + return; + } + window.localStorage.setItem(STORAGE_CADENCE_KEY, premiumBillingCadence); + }, [premiumBillingCadence, createFormHydrated, mode]); + const passwordValidation = mode === 'create' || password !== '' ? validatePassword(password, { @@ -162,6 +261,7 @@ export function UserForm({ mode, userId, initialValues, activeEditTab }: UserFor membershipTier: membershipTierToApiBodyValue(membershipTier), membershipExpiresAt: membershipExpiresAt.trim() === '' ? null : membershipExpiresAt, autoRenew, + ...(membershipTier === MembershipTier.Premium ? { premiumBillingCadence } : {}), }; const res = await managementWebUsers.createUser(apiBaseUrl, body); if (!res.ok) { @@ -252,6 +352,14 @@ export function UserForm({ mode, userId, initialValues, activeEditTab }: UserFor ); } + if (mode === 'create' && resolvedProductMembership === null) { + return ( + + {tCommon('loading')} + + ); + } + return ( { @@ -289,32 +397,48 @@ export function UserForm({ mode, userId, initialValues, activeEditTab }: UserFor autoComplete="new-password" /> + {t('passwordInviteHint')} - - + options={[ + { value: MembershipTier.Trial, label: t('membershipTierTrial') }, + { value: MembershipTier.Premium, label: t('membershipTierPremium') }, + ]} + /> {membershipTier === MembershipTier.Trial ? t('membershipTierTrialHelp') : t('membershipTierPremiumHelp')} + {membershipTier === MembershipTier.Premium && ( + <> + - - + options={[ + { value: MembershipTier.Trial, label: t('membershipTierTrial') }, + { value: MembershipTier.Premium, label: t('membershipTierPremium') }, + ]} + /> {membershipTier === MembershipTier.Trial ? t('membershipTierTrialHelp') diff --git a/apps/management-web/src/lib/createUserFormDefaults.ts b/apps/management-web/src/lib/createUserFormDefaults.ts new file mode 100644 index 00000000..de523c1a --- /dev/null +++ b/apps/management-web/src/lib/createUserFormDefaults.ts @@ -0,0 +1,41 @@ +import type { + MembershipTier, + PremiumBillingCadence, + ResolvedProductMembership, +} from '@metaboost/helpers'; + +import { + DEFAULT_FREE_TRIAL_EXPIRATION, + PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK, + resolveInitialMembershipExpiresAt, + toDateTimeLocalValue, +} from '@metaboost/helpers'; + +export const STORAGE_EXPIRY_KEY = 'metaboost-mgmt-create-user-membership-expires-at'; +export const STORAGE_CADENCE_KEY = 'metaboost-mgmt-create-user-premium-cadence'; + +/** Client fallback when management-api is unreachable; matches `resolveProductMembershipDefaultsFromEnv` bootstrap numbers. */ +export function fallbackProductMembershipFromEnv(): ResolvedProductMembership { + return { ...PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK }; +} + +/** + * Default datetime-local value aligned with server defaults (trial: `trialExpirationSeconds`; + * premium: +1 or +12 months via membership period policy). + */ +export function computeDefaultMembershipExpiresAtInput(options: { + membershipTier: MembershipTier; + premiumBillingCadence: PremiumBillingCadence; + trialExpirationSeconds: number; +}): string { + const sec = + options.trialExpirationSeconds > 0 + ? options.trialExpirationSeconds + : DEFAULT_FREE_TRIAL_EXPIRATION; + const d = resolveInitialMembershipExpiresAt({ + membershipTier: options.membershipTier, + premiumBillingCadence: options.premiumBillingCadence, + trialExpirationSeconds: sec, + }); + return toDateTimeLocalValue(d); +} diff --git a/apps/management-web/src/lib/main-nav.ts b/apps/management-web/src/lib/main-nav.ts index bccc3eee..70f04ba4 100644 --- a/apps/management-web/src/lib/main-nav.ts +++ b/apps/management-web/src/lib/main-nav.ts @@ -12,7 +12,12 @@ import { ROUTES } from './routes'; /** Permission keys that hold a CRUD bitmask. Used to gate tab visibility by read access. */ export type CrudPermissionKey = keyof Pick< ManagementUserPermissions, - 'adminsCrud' | 'usersCrud' | 'bucketsCrud' | 'bucketMessagesCrud' | 'bucketAdminsCrud' + | 'adminsCrud' + | 'usersCrud' + | 'bucketsCrud' + | 'bucketMessagesCrud' + | 'bucketAdminsCrud' + | 'billingPricesCrud' >; export type MainNavEntry = { @@ -34,6 +39,11 @@ export const MAIN_NAV_ENTRIES: MainNavEntry[] = [ labelKey: 'globalBlockedApps', readPermission: 'adminsCrud', }, + { + href: ROUTES.PRODUCTS_MEMBERSHIP, + labelKey: 'productsMembership', + readPermission: 'billingPricesCrud', + }, { href: ROUTES.EVENTS, labelKey: 'events' }, { href: ROUTES.TERMS_VERSIONS, labelKey: 'termsVersions', superAdminOnly: true }, { href: ROUTES.USERS, labelKey: 'users', readPermission: 'usersCrud' }, diff --git a/apps/management-web/src/lib/routes.ts b/apps/management-web/src/lib/routes.ts index 467de008..fc261ce7 100644 --- a/apps/management-web/src/lib/routes.ts +++ b/apps/management-web/src/lib/routes.ts @@ -18,6 +18,7 @@ export const ROUTES = { BUCKETS: '/buckets', BUCKETS_NEW: '/buckets/new', GLOBAL_BLOCKED_APPS: '/global-blocked-apps', + PRODUCTS_MEMBERSHIP: '/products/membership', } as const; /** Account settings tab; URL param ?tab= for profile, password. No email tab (management has no mailer). */ diff --git a/apps/management-web/src/types/management-api.ts b/apps/management-web/src/types/management-api.ts index ce991020..3564e83d 100644 --- a/apps/management-web/src/types/management-api.ts +++ b/apps/management-web/src/types/management-api.ts @@ -18,6 +18,7 @@ export type ManagementUserPermissions = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; }; diff --git a/docs/development/release/METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md b/docs/development/release/METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md index f4f16079..8da403d7 100644 --- a/docs/development/release/METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md +++ b/docs/development/release/METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md @@ -33,6 +33,8 @@ release before CI and clusters can render overlays. **`ghcr.io/podverse/metaboost/*`** **`newTag`** to the same **`VERSION_TAG`** (including **web-sidecar** and **management-web-sidecar**). Do **not** change third-party image pins (e.g. Postgres **`newTag`** under **db**). +**API and management-api overlays:** each must list **two** remote bases with the same **`?ref=`** — **`infra/k8s/base/api`** or **`infra/k8s/base/management-api`** **and** **`infra/k8s/base/product-membership`** (shared membership/pricing ConfigMap). If your GitOps repo previously referenced only the component base, add the **`product-membership`** URL or the Deployment’s **`envFrom`** will reference a missing ConfigMap. + Then validate from the GitOps repository root using whatever pin contract checks you maintain (for example `kubectl kustomize` on each overlay, or a script such as `scripts/check_metaboost_alpha_version_contract.sh` if your repo ships one). **Web** and **management-web** overlays use **two** `configMapGenerator` merges each (**`*-config`** + **`*-runtime-config`**); see your GitOps repo’s documentation for **metaboost-alpha** layout when applicable. diff --git a/infra/config/env-templates/api.env.example b/infra/config/env-templates/api.env.example index 8dd89f17..7600ea14 100644 --- a/infra/config/env-templates/api.env.example +++ b/infra/config/env-templates/api.env.example @@ -19,8 +19,12 @@ DB_APP_READ_USER="metaboost_app_read" DB_APP_READ_PASSWORD= DB_APP_READ_WRITE_USER="metaboost_app_read_write" DB_APP_READ_WRITE_PASSWORD= -MEMBERSHIP_DEFAULT_TRIAL_MONTHS= -MEMBERSHIP_DEFAULT_PREMIUM_MONTHS= +# Membership product bootstrap (main DB): seeds `product_membership_settings` / open `billing_price` +# windows when missing; at runtime the API reads the database first and uses these only as fallback / seed. +# MEMBERSHIP_FREE_TRIAL_EXPIRATION: trial length in seconds (default 31 days = 2678400 if unset). +MEMBERSHIP_FREE_TRIAL_EXPIRATION="2678400" +MEMBERSHIP_PREMIUM_COST_MONTHLY="3" +MEMBERSHIP_PREMIUM_COST_ANNUALLY="30" API_PORT="4000" API_VERSION_PATH="/v1" API_RELEASE="X.X.X-staging.N" diff --git a/infra/config/env-templates/management-api.env.example b/infra/config/env-templates/management-api.env.example index fc60415b..3358630e 100644 --- a/infra/config/env-templates/management-api.env.example +++ b/infra/config/env-templates/management-api.env.example @@ -24,8 +24,12 @@ DB_MANAGEMENT_READ_WRITE_USER="metaboost_management_read_write" DB_MANAGEMENT_READ_WRITE_PASSWORD= DB_MANAGEMENT_READ_USER="metaboost_management_read" DB_MANAGEMENT_READ_PASSWORD= -MEMBERSHIP_DEFAULT_TRIAL_MONTHS= -MEMBERSHIP_DEFAULT_PREMIUM_MONTHS= +# Membership bootstrap (same keys as main API): used only to INSERT missing rows into the main app DB +# (`product_membership_settings`, `billing_price`). Resolved values for UI/API come from the DB first. +# MEMBERSHIP_FREE_TRIAL_EXPIRATION: trial length in seconds (empty = default 31 days in helpers). +MEMBERSHIP_FREE_TRIAL_EXPIRATION="2678400" +MEMBERSHIP_PREMIUM_COST_MONTHLY="3" +MEMBERSHIP_PREMIUM_COST_ANNUALLY="30" API_PORT="4000" MANAGEMENT_API_VERSION_PATH="/v1" MANAGEMENT_API_RELEASE="X.X.X-staging.N" diff --git a/infra/k8s/INFRA-K8S-BASE.md b/infra/k8s/INFRA-K8S-BASE.md index 247f8b6d..d114b5be 100644 --- a/infra/k8s/INFRA-K8S-BASE.md +++ b/infra/k8s/INFRA-K8S-BASE.md @@ -12,14 +12,15 @@ remote bases. ## Layout -| Directory | Workloads / resources | -| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `base/api/` | **configMapGenerator** `metaboost-api-config` from `source/api.env`, Service `api`, Deployment `api` | -| `base/web/` | ConfigMaps from `source/web.env` + `source/web-sidecar.env`, Service `web`, Deployment `web` (web + sidecar containers, Podverse-style) | -| `base/management-api/` | **configMapGenerator** `metaboost-management-api-config` from `source/management-api.env`, Service, Deployment | -| `base/management-web/` | ConfigMaps from `source/management-web.env` + `source/management-web-sidecar.env`, Service `management-web`, Deployment `management-web` (dual container) | -| `base/db/` | Service `metaboost-db`, StatefulSet `metaboost-db` (+ generated PVC via `volumeClaimTemplates`) | -| `base/keyvaldb/` | PVC `metaboost-valkey-data`, Service `metaboost-keyvaldb`, Deployment `valkey` | +| Directory | Workloads / resources | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `base/product-membership/` | **configMapGenerator** `metaboost-product-membership-config` from `source/product-membership-settings.env` (composed in **`alpha/api`** and **`alpha/management-api`**, not nested under another base) | +| `base/api/` | **configMapGenerator** `metaboost-api-config` from `source/api.env`, Service `api`, Deployment `api` | +| `base/web/` | ConfigMaps from `source/web.env` + `source/web-sidecar.env`, Service `web`, Deployment `web` (web + sidecar containers, Podverse-style) | +| `base/management-api/` | **configMapGenerator** `metaboost-management-api-config` from `source/management-api.env`, Service, Deployment | +| `base/management-web/` | ConfigMaps from `source/management-web.env` + `source/management-web-sidecar.env`, Service `management-web`, Deployment `management-web` (dual container) | +| `base/db/` | Service `metaboost-db`, StatefulSet `metaboost-db` (+ generated PVC via `volumeClaimTemplates`) | +| `base/keyvaldb/` | PVC `metaboost-valkey-data`, Service `metaboost-keyvaldb`, Deployment `valkey` | **ConfigMap defaults** live under `infra/k8s/base/*/source/*.env` (kustomize **env file** semantics: prefer **unquoted** values so generated `ConfigMap.data` does not embed stray quote characters). @@ -46,7 +47,7 @@ From monorepo root (with kubectl on PATH): Or manually: ```bash -for d in api web management-api management-web db keyvaldb ops; do +for d in api web management-api management-web product-membership db keyvaldb ops; do kubectl kustomize "infra/k8s/base/$d" --load-restrictor LoadRestrictionsNone >/dev/null \ && echo "ok base/$d" || echo "FAIL base/$d" done diff --git a/infra/k8s/INFRA-K8S.md b/infra/k8s/INFRA-K8S.md index d3a2fbd4..e0ca5fc6 100644 --- a/infra/k8s/INFRA-K8S.md +++ b/infra/k8s/INFRA-K8S.md @@ -14,6 +14,7 @@ Cluster-shaped validation uses a **remote** cluster and your **GitOps** reposito ## Per-component `base/*` - **`infra/k8s/base//`** (api, web, db, keyvaldb, …) — Kustomize bases consumed by remote GitOps overlays via `resources:` URLs (see [REMOTE-K8S-GITOPS.md](../../docs/development/k8s/REMOTE-K8S-GITOPS.md)) and referenced by in-repo **`infra/k8s/alpha//`** kustomizations. +- Compose shared cross-component bundles (for example **`base/product-membership`**) explicitly in **environment overlays** (`alpha/api`, `alpha/management-api`, or your GitOps equivalents), not via `../` sibling paths inside `base//`. ## Base DB source SQL @@ -49,5 +50,4 @@ Remote deployment uses **your** GitOps repository (Kustomize overlays, Argo CD ` ## Revision policy - Alpha app-of-apps and alpha child Application manifests (`infra/k8s/alpha-application.yaml`, `infra/k8s/alpha/apps/*`) use immutable Git revisions in committed manifests. -- Alpha child overlays that use remote `resources:` URLs pin immutable Git revisions in committed manifests. -- Alpha **`ops`** currently composes from in-repo **`../../base/ops`** because **`infra/k8s/base/ops`** is not published on the public remote refs consumed by Kustomize. +- Alpha child overlays that use remote `resources:` URLs pin immutable Git revisions in committed manifests (including **`alpha/ops`**, which references **`base/ops`** the same way as other components). diff --git a/infra/k8s/alpha/api/kustomization.yaml b/infra/k8s/alpha/api/kustomization.yaml index 7de601ee..c988b3be 100644 --- a/infra/k8s/alpha/api/kustomization.yaml +++ b/infra/k8s/alpha/api/kustomization.yaml @@ -6,5 +6,6 @@ kind: Kustomization resources: - https://github.com/podverse/metaboost//infra/k8s/base/api?ref=X.X.X-staging.N + - https://github.com/podverse/metaboost//infra/k8s/base/product-membership?ref=X.X.X-staging.N namespace: metaboost-alpha diff --git a/infra/k8s/alpha/management-api/kustomization.yaml b/infra/k8s/alpha/management-api/kustomization.yaml index 15aec33e..2f689bb5 100644 --- a/infra/k8s/alpha/management-api/kustomization.yaml +++ b/infra/k8s/alpha/management-api/kustomization.yaml @@ -3,5 +3,6 @@ kind: Kustomization resources: - https://github.com/podverse/metaboost//infra/k8s/base/management-api?ref=X.X.X-staging.N + - https://github.com/podverse/metaboost//infra/k8s/base/product-membership?ref=X.X.X-staging.N namespace: metaboost-alpha diff --git a/infra/k8s/alpha/ops/kustomization.yaml b/infra/k8s/alpha/ops/kustomization.yaml index 5c6c5268..88d938b8 100644 --- a/infra/k8s/alpha/ops/kustomization.yaml +++ b/infra/k8s/alpha/ops/kustomization.yaml @@ -2,6 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - ../../base/ops + - https://github.com/podverse/metaboost//infra/k8s/base/ops?ref=X.X.X-staging.N namespace: metaboost-alpha diff --git a/infra/k8s/base/api/deployment.yaml b/infra/k8s/base/api/deployment.yaml index 6540049e..8932edb7 100644 --- a/infra/k8s/base/api/deployment.yaml +++ b/infra/k8s/base/api/deployment.yaml @@ -71,6 +71,8 @@ spec: name: metaboost-db-secrets - secretRef: name: metaboost-keyvaldb-secrets + - configMapRef: + name: metaboost-product-membership-config env: - name: DB_HOST value: metaboost-db diff --git a/infra/k8s/base/api/source/api.env b/infra/k8s/base/api/source/api.env index 08a4d693..b8de1d8c 100644 --- a/infra/k8s/base/api/source/api.env +++ b/infra/k8s/base/api/source/api.env @@ -20,9 +20,8 @@ DB_PORT=5432 DB_APP_NAME=metaboost_app DB_APP_READ_USER=metaboost_app_read DB_APP_READ_WRITE_USER=metaboost_app_read_write -MEMBERSHIP_DEFAULT_TRIAL_MONTHS= -MEMBERSHIP_DEFAULT_PREMIUM_MONTHS= -API_EXPECTED_MIGRATION_FILENAME=0005_remove_user_trust_tier.sql +# MEMBERSHIP_FREE_TRIAL_EXPIRATION, MEMBERSHIP_PREMIUM_COST_*: metaboost-product-membership-config +API_EXPECTED_MIGRATION_FILENAME=0005_billing_domain_events.sql API_PORT=4000 API_VERSION_PATH=/v1 API_RELEASE=X.X.X-staging.N 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 800f80b2b89e80154f28c08ec527da9f17a1e76d..001824f7e244d824dbea70806c3d7c30720d73d3 100644 GIT binary patch literal 9197 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 DF+sq| literal 7597 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 PYVZF5PK-gRsLcQXz8lah diff --git a/infra/k8s/base/db/source/bootstrap/0003b_management_linear_baseline.sql.gz b/infra/k8s/base/db/source/bootstrap/0003b_management_linear_baseline.sql.gz index 77e2ffba076f7d8da94fc12631712806fbd8bd61..6dec1680ff412baf09d550521932ac084ee52b40 100644 GIT binary patch literal 2698 zcmV;53U&1#iwFP!000001I=9Ra-%pF{hy~$%^%w}I1_h1CW$v&McX8CwT(0Wn4Z+` z6oo;ysbKH`PCD~zA7Y)+@b?(uX?$L#$*+dVLERDp8)J6Bb zzJ%(!jV#+kSU0=|`rm*4i(HJ6t2xHnbAQoQPo1eQ{$+tJ?5G}Ynudi{r%7&-|2=h= zCnwD&8mN}Kz$#EHX~320 zqV8v9Aj;x{G?3(pGUz=3;^9b{j-^rKln&_QP(p*s(r`W7d8f9J;Sy`u(ur{lBdrVz z+6_%L&380+>{SE>%?%S<>I%C65P)hg?q=oEQOH0~w(Bh%JpQd8d8ThxYtl@Sbm0Pq zp+>_Vuh?;k?bh|_IjVQ@%uxM{X6tml&=;-qpIZ&)>DfD4FBD?8;G-vFX@Xo2SR|w? zp0UFA#zS}LtWBFtTYE|0HFOgzhNXCpYPqULiF2__#!Gi*JEa;mm?{_-TbivK)&d%8 z)5-mHtsMZfbk)%n&9>$?VSk~xo&*25(9^a^ufEio5T`ZZ%(NG$F7{}5V^OcUFt^%L zS*zZ%CZHPpH&;y;H@0JwNmAw;%f~hY1en;Ref%!pR;InCvwMfYs0UbMxB-y@Gwwpr zk|*C(#{zJE7sq}BQnZa@c<%)5eBKU%L5487g%#q!BvDS3H^W;hI-aW=(`%#2`x@H2 z!-3e7+h|O~{uZq_Gt68&w% z^6ERX36Zc+)~f5i*^YiV#-v4pjog4p!44Vk9hj_Non=CCm$p*@ zS21&Z5SQ5?Jo^R1rlGpa>X1Y>GdQ`Ra7c`8=T(92!o==c)i7B>uzJ$1imGW4sSvK- z($=Bd607S(M5;Oo=r!5r=wfyQrzYa(zEsR7+BI+~Wf0X*5d87)8#>x-;4gVNLGrZU z7os*-(VUHr3|Mjta6O34vqawwM0&xlvyJwGy;7aSrCsWwGWchPX_8HC?HF3oVel*2 zd0q|IfTzjc;^A*z*t-U)X$zrtdrO<*k9UUhcrt>po1~|k#jeop5p^D=&KFd#*IBpz z3Hx^giEZgpKdKbdMv&&#*fEf z8bQ3hG=^PcXN_5YtYpk4#!H3t9Srx3>f&f5KL4v`zggQ@7C7=GK?2^AOE1S0aWp|+dy_}hqBnXn z7$>I-^!a&vOCF*@PySu(Po?l${3E*Vh!AY3b)d^=_*I6(*XQRP-^Kn!8kKcDG@jT0 z)Xa zuNL8BI*J})hN4bYxCv8xd+xuWBW#D9{Wv>}-{t|#>{i`y*QWZe(0!be9yF3V4d*dW zbruk$q1D*kEe`+QPoZ%6spJl)V^1|qm$Aj4b3eKwHxtv%_#Y2DcLVa(j~FJ@n9D!& zLcqau2fItZx=>*##O&@F5{x+#*%^farY@3QF-c^#Z)*e4r8OxjLPoMQTZkr*RmdAL zwz7rf?%=`MJH?pG5M)=oVwQ->EmOq;CALHCRC@XDfd4vN z7{F=OZZW1yxks9cirApK%3U^L>9zbp3}QAcc=};i=p5qnF_incCky01&>8lp1DUK4 zQqT;KkFeprs%E`b>^K8kq=Ofl3GOlucF7x_x$P9HeWI>75%cDJ`1HJ^@EO!R1@>ei zFoy^sc3u#;M+1R#3J`dLUq~tX2D%>&2gvnw!)l-W`!tJ)`~zHBGBQqYgbEVJ--{4| z*%kL7C#|Ut;AJbQXmo7#k-(@Sfuntdt>h6{fg?n^MquTP5Ga&A9L-h^2RNLN4Fqm_ z_Y;_$FEGh`4<}OD`vB~I>L&2wnV1bIfytbE!cC>p10nl)l*cj~R{Mg@N|c9!j%7(6 z_#hn$@no0j$A0BDNIyAFa9>52$x;GpLBKm3oXW!KY?3Z%uU~LjX^zewduHX7dA}Ii z?d~qC6Ab8S)EkJSXY@sSj*?z+VUfvFx}2?4>2lV%JkLvFL{qs3VFcluYOa8nN+-S& z7SdtB2>`w*;Fm`#pKR_hJRf)C2`832m1R7vbP^BruI%*r+9g%wIF?J)dX6^{f-;W8 zHAz;((Hx77lH*_+Q5i==HNd$S)0oFvbirUbUS?tn7hX)c?CbHdCbX=V>1j;soiYWB zU2Vs8ni;5w^GRGEMna$+j~eF(kip^$V?MZ<$3n{TF{iwUXN9&YL;t2^DvRhFgTRlT z+?D<)R*a)vi`oZYb4ygyaZi3gGta>oly3BBR~mP?e(_%f9OMe$B!fPcsN<-&#fD;V z!+JuskfYLkQP@mnBM*5zQ${s}s$9sj&-W!RF=9S3<&9D~!z zJzCD2?GAsO*$Im3Wt}OIfylVFh1qgY6_b)M$wy8%`xxMOU8;e;K1w4gc^lpBUu_7^ z^~#*dDfeH#Tje2!2i*@xQtv@dlpxSZx(6EMjx^37k>n#naaZa~FdH3l+!4Fd;U|W4 z{8Y|cCFh6?oN+!mi{S{&?iB1F%V${`|AVo}6PVk=^s!khD0f*jahBK`dRcbJ3q$f! zv#PVgcL=%zP7dEY2*;gAX&{E~`y7d#e#A^OLCV8O1fRrypBRrljwVC2!k#*_ZP#Nx z^CdYb=)~YYvzc#nV=%ZA(2RJejaiLu$A;m4{62pFDaAZEHu&a`WT!V$Y zXFpl^00!s*p=uU#jTU2q^F72zFv6YX0NakagU_`y7#N_7iaUlwige9Hscf%=2I-TGjhw&n@{)0?I0W8rRWc|Jm zq}i*ri9Jk7J<0s7C%&X(e!WQWA%@QspamRxvY7M1iNN{s&IY1Coj8UwX1#WHcGhCu z@_*^dsr#2neuaj1sa-o+6>etK5i|bnB1ek;~?0vrntn$8yLERzwAo;N;KeSK&5B)%_g=$6s E0K|(wl>h($ literal 2661 zcmV-r3YzsFiwFP!000001I=7*bECEv{+?f<=@;8moW`5CHtF6O)+UK(9h}-ux0&7! zgF&{LU~mymHvM#e#QlZ$m)s*^K!7k{7kqc zKtWB@MULwuY+8N`{qH~jMFe9+49{BmGPxo=#wj8W`ZF-ab?=e{% z9ktu&UU&34USh{b;WH@ggPO!x;H8d!L`!Q9RxH9+L`HrXTBNMEgN3$Ap6 zdY`m=S(R^m^~M(19LY;?F%i{%sI5GuO)%ZDvS%Z~?=A0a^Yig3YWDEd(u0e3=XfvpO z-Cr~$)PVnHx=nCvGd7(hZMJp-Y*Rphsa@75=<;=GyDK)k9}yV!0c!#`AktvQ2{Ta702g%e+*c6ASbLWLM$yh@*P%10;3ikFLOhrxriu1y`3ud&GktCQ*J$##g7)6< zUhb>cXw2OH3a!>t+cK`v@D&!(6>2lr>5ddwg`2*cF2{M6kvvB9Cg=(tLR;7+`p1Rk z)l+Wd$H4@a@oN(lN@9JXLc&5>>4dzxp1D89v_*=I-T+U*9v$xulx$v{SLRE)nL1@FTrV8$H72~ z+lXd(YZF7gq4@eAEWUMylwEt~EWKK|-Q zy&Di_Hs%^vSF9=dcz39dCnIp7Np|XZ92(o|Q1?#hen!n^lZW^(*o#|8R(mLM``3@C zbKYvTN(_|cogPo13^%Nxwfg9Zsmd8YIh|Pq$@a<^wvC-PX7#bsE|VHB<53@BxF^(< zMJn<4tGv^j3$D6wwvOdaeuX$4iz0Mzdb5yx1^|H z?;>3{gD_GM%QoV&%ke}WP0*MA8XIbJeVk>s?;OnMg5O0GtFZeorZ#Cs(G%B_gw-)qp|<9fE@Z3 z9M?3+-c5I@(fiG`pR$D@%%h4!dSM_h5xy$~?b4yTOn^@IF+2?CC$?IP^6%X6Eu57@ zg>dm;?-|CoB6!D8z75AA!nt_7ZyVCZm<|%wxuF=T8uv10Z^vy6!m;bo(}-Yk2W>vU z%x^U0nu z*Odk6vZ);{i^bVpem<8%R-UZtMC1GcOtLD2W|nYr<4a2l$(CVS%P~)8b55qDPy`=7M3pPq=KNVqo?24EStRf(zWP zR5~B%F;oYI2|5ZM=ne;u_bOc>w8R#j!eGOD(d}lV+^GPzNDt2q8|ZQbd-R>d%=JoD z<4{kY$VGELe0bVYiVLf75}%+;TrMsp(r!tZVM~H!TFT_jEp#&)-Xr3hmUDgd@8dkq z#Ydj1ymQVwlr?qPci=8@d)*E?WHY5bB-o@$Y|b}XkHqCTk|3jzHi?YHr!7*Ptw?;5 zB87R!4n*_wfjtPPk^u@gyQfK9B$v1ly#t9%Ox^=~5RfUnWPIfvO5)<-j&L&pa!<%X zbQGz~yVb5>^TOZ0pcAoA1U?LMQZj_)^4P5w1wo~Ngz%MTmDnSp4miBG#+lffjS<;; z@9G&xW!U&cv16>wm=7w2z25ekGsS=&M*Vwv^n^YuPjM(KpDyI8l&$q@byCh7SH!#` zM)aum!HvLuGtD*VQtQOmqC(aU1P&mM|Kg%Y?UOAWM#OPDo@io)Q(1*!t&_MXyQji9L1XYM6bCSG`#bB{tTztbO2GSa^CI_tjf8^*xLs(~TePDdVn?m$GcKmweEw#YhXfvUGaM=XTQyaJKqKV_WKg@xI7dVpkEp~w zWuU-pbmehZ?kW497_$CTyKc3dBXV#i^%N|I11P&SVSisetETbanTnzWb5oc;v}z^O zT^>zRCBBAURUP^okiM#{>#Xn%oNkYk!?$+AaraKSmm~UtMsjx$E7MMiayycNlROwu zZ@aAG?4bUjdbsDD>Ql-y!cJ zN}Xef{buX?{zEv8xLCfo$5c04s7K zv4z-=?v3G$*=(GgoOF0v!7ukXCV$)XS2dW^b9^@K;OQ^AIlVmja5|k`%r1-%r{={) zr*mYeiw^Wt=Rc5 TIMESTAMP '1970-01-01 00:00:00') ); @@ -21,8 +20,6 @@ CREATE TRIGGER set_updated_at_user_trust_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at_field(); -INSERT INTO user_trust_settings (user_id, membership_tier, auto_renew, trust_tier_id) -SELECT u.id, 'trial', false, 1 -FROM "user" AS u -LEFT JOIN user_trust_settings uts ON uts.user_id = u.id -WHERE uts.user_id IS NULL; +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_trust_renewal.sql new file mode 100644 index 00000000..047283ae --- /dev/null +++ b/infra/k8s/base/ops/source/database/linear-migrations/app/0004_billing_catalog_and_trust_renewal.sql @@ -0,0 +1,138 @@ +-- Forward-only: billing product/price catalog, membership trial settings, renewal metadata on trust settings. + +CREATE TABLE billing_product ( + id SERIAL PRIMARY KEY, + product_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at server_time_with_default NOT NULL, + updated_at server_time_with_default NOT NULL +); + +CREATE TRIGGER set_updated_at_billing_product + BEFORE UPDATE ON billing_product + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_field(); + +CREATE TABLE billing_price ( + id SERIAL PRIMARY KEY, + billing_product_id INTEGER NOT NULL REFERENCES billing_product(id) ON DELETE CASCADE, + currency_code CHAR(3) NOT NULL, + billing_cadence TEXT NOT NULL CHECK (billing_cadence IN ('monthly', 'annual')), + amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0), + effective_from TIMESTAMP NOT NULL DEFAULT NOW(), + effective_to TIMESTAMP, + source TEXT NOT NULL DEFAULT 'manual', + created_at server_time_with_default NOT NULL, + updated_at server_time_with_default NOT NULL, + CONSTRAINT billing_price_effective_window_check CHECK ( + effective_to IS NULL OR effective_to > effective_from + ), + CONSTRAINT billing_price_unique_window_start UNIQUE ( + billing_product_id, + currency_code, + billing_cadence, + effective_from + ) +); + +CREATE TRIGGER set_updated_at_billing_price + BEFORE UPDATE ON billing_price + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_field(); + +CREATE UNIQUE INDEX uq_billing_price_open_window + ON billing_price (billing_product_id, currency_code, billing_cadence) + WHERE effective_to IS NULL; + +CREATE INDEX idx_billing_price_effective_lookup + ON billing_price (billing_product_id, billing_cadence, currency_code, effective_from DESC); + +CREATE TABLE billing_price_change_audit ( + id SERIAL PRIMARY KEY, + billing_price_id INTEGER REFERENCES billing_price(id) ON DELETE SET NULL, + changed_by_management_user_id UUID, + change_reason TEXT, + previous_amount_cents INTEGER CHECK (previous_amount_cents IS NULL OR previous_amount_cents >= 0), + new_amount_cents INTEGER CHECK (new_amount_cents IS NULL OR new_amount_cents >= 0), + previous_effective_from TIMESTAMP, + previous_effective_to TIMESTAMP, + new_effective_from TIMESTAMP, + new_effective_to TIMESTAMP, + created_at server_time_with_default NOT NULL +); + +INSERT INTO billing_product (product_code, name, is_active) +VALUES ('membership_premium', 'Premium Membership', TRUE); + +INSERT INTO billing_price ( + billing_product_id, + currency_code, + billing_cadence, + amount_cents, + effective_from, + effective_to, + source +) +SELECT bp.id, 'USD', 'monthly', 300, TIMESTAMP '2000-01-01 00:00:00', NULL, 'seed' +FROM billing_product bp +WHERE bp.product_code = 'membership_premium'; + +INSERT INTO billing_price ( + billing_product_id, + currency_code, + billing_cadence, + amount_cents, + effective_from, + effective_to, + source +) +SELECT bp.id, 'USD', 'annual', 3000, TIMESTAMP '2000-01-01 00:00:00', NULL, 'seed' +FROM billing_product bp +WHERE bp.product_code = 'membership_premium'; + +CREATE TABLE product_membership_settings ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + free_trial_expiration_seconds INTEGER NOT NULL CHECK (free_trial_expiration_seconds > 0), + created_at server_time_with_default NOT NULL, + updated_at server_time_with_default NOT NULL +); + +CREATE TRIGGER set_updated_at_product_membership_settings + BEFORE UPDATE ON product_membership_settings + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_field(); + +INSERT INTO product_membership_settings ( + id, + free_trial_expiration_seconds, + created_at, + 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/infra/k8s/base/ops/source/database/linear-migrations/app/0005_billing_domain_events.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0005_billing_domain_events.sql new file mode 100644 index 00000000..44855a3b --- /dev/null +++ b/infra/k8s/base/ops/source/database/linear-migrations/app/0005_billing_domain_events.sql @@ -0,0 +1,25 @@ +-- Forward-only: append-only billing domain events for orchestration and audit. + +CREATE TABLE billing_domain_event ( + id SERIAL PRIMARY KEY, + event_type TEXT NOT NULL CHECK (event_type IN ( + 'payment_settled', + 'renewal_succeeded', + 'renewal_failed', + 'pay_on_demand_extension_requested' + )), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + idempotency_key VARCHAR(128), + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at server_time_with_default NOT NULL +); + +CREATE UNIQUE INDEX uq_billing_domain_event_idempotency_key + ON billing_domain_event (idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE INDEX idx_billing_domain_event_user_created + ON billing_domain_event (user_id, created_at DESC); + +CREATE INDEX idx_billing_domain_event_type_created + ON billing_domain_event (event_type, created_at DESC); diff --git a/infra/k8s/base/ops/source/database/linear-migrations/app/0005_remove_user_trust_tier.sql b/infra/k8s/base/ops/source/database/linear-migrations/app/0005_remove_user_trust_tier.sql deleted file mode 100644 index d23edc40..00000000 --- a/infra/k8s/base/ops/source/database/linear-migrations/app/0005_remove_user_trust_tier.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Forward-only: hard break trust-tier semantics from user_trust_settings. --- Runtime eligibility is membership-tier + expiry driven; advanced overrides remain separate. - -ALTER TABLE user_trust_settings -DROP CONSTRAINT IF EXISTS chk_user_trust_settings_trust_tier_id; - -ALTER TABLE user_trust_settings -DROP COLUMN IF EXISTS trust_tier_id; diff --git a/infra/k8s/base/ops/source/database/linear-migrations/management/0001_management_schema.sql b/infra/k8s/base/ops/source/database/linear-migrations/management/0001_management_schema.sql index cec1b437..7b4f3944 100644 --- a/infra/k8s/base/ops/source/database/linear-migrations/management/0001_management_schema.sql +++ b/infra/k8s/base/ops/source/database/linear-migrations/management/0001_management_schema.sql @@ -13,7 +13,7 @@ CREATE DOMAIN server_time_with_default AS TIMESTAMPTZ DEFAULT NOW(); -- 0001 migration: management_user – super admin singleton + admins (with credentials and bio 1:1) -- Core management user row (no email/password here; see management_user_credentials) -CREATE TABLE IF NOT EXISTS management_user ( +CREATE TABLE management_user ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), is_super_admin BOOLEAN NOT NULL DEFAULT false, created_at server_time_with_default NOT NULL, @@ -21,18 +21,18 @@ CREATE TABLE IF NOT EXISTS management_user ( ); -- At most one row with is_super_admin = true -CREATE UNIQUE INDEX IF NOT EXISTS idx_one_super_admin ON management_user(is_super_admin) WHERE is_super_admin = true; +CREATE UNIQUE INDEX idx_one_super_admin ON management_user(is_super_admin) WHERE is_super_admin = true; -- Credentials: username and password (1:1 with management_user); management-web auth is username-only. -- JWT `id_text` matches username; `isValidManagementJwtIdText` enforces 1..USERNAME_MAX_LENGTH (varchar_short = 50). -CREATE TABLE IF NOT EXISTS management_user_credentials ( +CREATE TABLE management_user_credentials ( management_user_id UUID PRIMARY KEY REFERENCES management_user(id) ON DELETE CASCADE, username varchar_short UNIQUE NOT NULL, password_hash varchar_password NOT NULL ); -- Bio: display name (1:1 with management_user); unique so admins are distinguishable without ID -CREATE TABLE IF NOT EXISTS management_user_bio ( +CREATE TABLE management_user_bio ( management_user_id UUID PRIMARY KEY REFERENCES management_user(id) ON DELETE CASCADE, display_name varchar_short NOT NULL UNIQUE ); @@ -47,13 +47,14 @@ CREATE TABLE IF NOT EXISTS management_user_bio ( -- bucket_admins_crud: per-bucket admin/invitation management (list, create, update, delete). -- Capabilities such as changing passwords or assigning permissions are implied by -- the relevant CRUD bits (e.g. update on users implies password changes). -CREATE TABLE IF NOT EXISTS admin_permissions ( +CREATE TABLE admin_permissions ( admin_id UUID PRIMARY KEY REFERENCES management_user(id) ON DELETE CASCADE, admins_crud INTEGER NOT NULL DEFAULT 0 CHECK (admins_crud >= 0 AND admins_crud <= 15), users_crud INTEGER NOT NULL DEFAULT 0 CHECK (users_crud >= 0 AND users_crud <= 15), buckets_crud INTEGER NOT NULL DEFAULT 0 CHECK (buckets_crud >= 0 AND buckets_crud <= 15), bucket_messages_crud INTEGER NOT NULL DEFAULT 0 CHECK (bucket_messages_crud >= 0 AND bucket_messages_crud <= 15), bucket_admins_crud INTEGER NOT NULL DEFAULT 0 CHECK (bucket_admins_crud >= 0 AND bucket_admins_crud <= 15), + billing_prices_crud INTEGER NOT NULL DEFAULT 0 CHECK (billing_prices_crud >= 0 AND billing_prices_crud <= 15), event_visibility TEXT NOT NULL DEFAULT 'all_admins' CHECK(event_visibility IN ('own', 'all_admins', 'all')) ); @@ -63,7 +64,7 @@ CREATE TABLE IF NOT EXISTS admin_permissions ( -- Audit log: every action by super admin or admin -- actor_display_name is stored at event time so it survives admin deletion or display name changes. -CREATE TABLE IF NOT EXISTS management_event ( +CREATE TABLE management_event ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), actor_id TEXT NOT NULL, actor_type TEXT NOT NULL CHECK(actor_type IN ('super_admin', 'admin')), @@ -75,8 +76,8 @@ CREATE TABLE IF NOT EXISTS management_event ( details TEXT ); -CREATE INDEX IF NOT EXISTS idx_management_event_actor ON management_event(actor_id); -CREATE INDEX IF NOT EXISTS idx_management_event_timestamp ON management_event(timestamp); +CREATE INDEX idx_management_event_actor ON management_event(actor_id); +CREATE INDEX idx_management_event_timestamp ON management_event(timestamp); -- Including: 0004_management_refresh_token.sql @@ -108,19 +109,7 @@ CREATE TABLE management_admin_role ( buckets_crud INTEGER NOT NULL CHECK (buckets_crud >= 0 AND buckets_crud <= 15), bucket_messages_crud INTEGER NOT NULL CHECK (bucket_messages_crud >= 0 AND bucket_messages_crud <= 15), bucket_admins_crud INTEGER NOT NULL CHECK (bucket_admins_crud >= 0 AND bucket_admins_crud <= 15), + billing_prices_crud INTEGER NOT NULL DEFAULT 0 CHECK (billing_prices_crud >= 0 AND billing_prices_crud <= 15), event_visibility TEXT NOT NULL CHECK(event_visibility IN ('own', 'all_admins', 'all')), created_at server_time_with_default NOT NULL ); - - --- Including: linear migration metadata baseline -CREATE TABLE IF NOT EXISTS linear_migration_history ( - id SERIAL PRIMARY KEY, - migration_filename VARCHAR(255) NOT NULL UNIQUE, - migration_checksum VARCHAR(64) NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_linear_migration_history_applied_at - ON linear_migration_history(applied_at DESC); - diff --git a/infra/k8s/base/product-membership/kustomization.yaml b/infra/k8s/base/product-membership/kustomization.yaml new file mode 100644 index 00000000..3764d1a9 --- /dev/null +++ b/infra/k8s/base/product-membership/kustomization.yaml @@ -0,0 +1,11 @@ +# Shared membership / pricing env (api + management-api). +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +configMapGenerator: + - name: metaboost-product-membership-config + envs: + - source/product-membership-settings.env + +generatorOptions: + disableNameSuffixHash: true diff --git a/infra/k8s/base/product-membership/source/product-membership-settings.env b/infra/k8s/base/product-membership/source/product-membership-settings.env new file mode 100644 index 00000000..85547f0e --- /dev/null +++ b/infra/k8s/base/product-membership/source/product-membership-settings.env @@ -0,0 +1,5 @@ +# Trial length (seconds) and premium pricing (USD). Overlap with source/api.env is intentional; +# this ConfigMap is mounted last on api and management-api so it wins. +MEMBERSHIP_FREE_TRIAL_EXPIRATION=2678400 +MEMBERSHIP_PREMIUM_COST_MONTHLY=3 +MEMBERSHIP_PREMIUM_COST_ANNUALLY=30 diff --git a/makefiles/local/Makefile.local.test.mk b/makefiles/local/Makefile.local.test.mk index 886ec04e..fbdf0b7b 100644 --- a/makefiles/local/Makefile.local.test.mk +++ b/makefiles/local/Makefile.local.test.mk @@ -127,6 +127,7 @@ test_db_init: test_postgres_up @docker exec $(TEST_PG_CONTAINER) psql -U $(TEST_PG_USER) -d $(TEST_DB_NAME) -c "GRANT USAGE, CREATE ON SCHEMA public TO $(TEST_APP_MIGRATOR_USER);" @METABOOST_LOCAL_PG_CONTAINER="$(TEST_PG_CONTAINER)" DB_HOST="127.0.0.1" DB_PORT="$(TEST_DB_PORT)" DB_APP_NAME="$(TEST_DB_NAME)" DB_APP_MIGRATOR_USER="$(TEST_APP_MIGRATOR_USER)" DB_APP_MIGRATOR_PASSWORD="$(TEST_APP_MIGRATOR_PASSWORD)" \ bash scripts/database/run-linear-migrations.sh --database app + @docker exec $(TEST_PG_CONTAINER) psql -U $(TEST_PG_USER) -d $(TEST_DB_NAME) -c "REASSIGN OWNED BY $(TEST_APP_MIGRATOR_USER) TO $(TEST_APP_READ_WRITE_USER);" @docker exec $(TEST_PG_CONTAINER) psql -U $(TEST_PG_USER) -d $(TEST_DB_NAME) -c " \ GRANT CONNECT ON DATABASE $(TEST_DB_NAME) TO $(TEST_APP_READ_USER), $(TEST_APP_READ_WRITE_USER); \ GRANT USAGE ON SCHEMA public TO $(TEST_APP_READ_USER), $(TEST_APP_READ_WRITE_USER); \ diff --git a/package-lock.json b/package-lock.json index df9ef4fd..1c251edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -249,6 +249,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -446,7 +447,6 @@ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -509,39 +509,6 @@ "node": ">=10.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1375,9 +1342,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1394,9 +1358,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1413,9 +1374,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1432,9 +1390,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1451,9 +1406,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1470,9 +1422,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1489,9 +1438,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1508,9 +1454,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1527,9 +1470,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1552,9 +1492,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,9 +1514,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1602,9 +1536,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1627,9 +1558,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1652,9 +1580,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1677,9 +1602,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1702,9 +1624,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2041,9 +1960,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2060,9 +1976,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2079,9 +1992,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2098,9 +2008,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2309,9 +2216,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2332,9 +2236,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2355,9 +2256,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2378,9 +2276,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2401,9 +2296,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2424,9 +2316,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2670,9 +2559,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2690,9 +2576,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2710,9 +2593,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2730,9 +2610,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2750,9 +2627,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2770,9 +2644,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3148,9 +3019,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3167,9 +3035,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3186,9 +3051,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3205,9 +3067,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3224,9 +3083,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3243,9 +3099,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3405,8 +3258,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3619,6 +3471,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -3724,6 +3577,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3852,6 +3706,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -4274,6 +4129,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4572,6 +4428,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5424,8 +5281,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -5591,6 +5447,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5660,6 +5517,7 @@ "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5929,6 +5787,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7204,9 +7063,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7228,9 +7084,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7252,9 +7105,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7276,9 +7126,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7564,7 +7411,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7817,6 +7663,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", @@ -7942,16 +7789,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/node-addon-api": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", @@ -8331,6 +8168,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -8591,6 +8429,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8607,7 +8446,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8623,7 +8461,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8717,6 +8554,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8771,6 +8609,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8790,6 +8629,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8907,7 +8747,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -9580,6 +9421,7 @@ "integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -9906,6 +9748,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10049,6 +9892,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -10110,6 +9954,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10315,6 +10160,7 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -10815,6 +10661,7 @@ "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index bc450129..373aa902 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,7 @@ "node": ">=24.0.0" }, "overrides": { - "glob": "^11.0.0", "postcss": "^8.5.10", - "punycode": "^2.3.1", "uuid": "^14.0.0" }, "workspaces": [ @@ -82,6 +80,7 @@ "db:status:linear:k8s:management": "bash scripts/database/print-linear-migrations-status-k8s.sh --database management", "db:validate:linear": "bash scripts/database/validate-linear-migrations.sh", "db:validate:linear:db": "bash scripts/database/validate-linear-migrations.sh --check-db", + "billing:process-due-renewals": "node scripts/api/process-due-renewals.mjs", "management:superuser:create": "node scripts/management-api/create-super-admin.mjs", "management:superuser:update": "node scripts/management-api/update-super-admin.mjs", "management:superuser:create:k8s": "bash scripts/management-api/run-superuser-k8s-job.sh create", diff --git a/packages/helpers-requests/src/index.ts b/packages/helpers-requests/src/index.ts index 9441c9a4..6dd13f58 100644 --- a/packages/helpers-requests/src/index.ts +++ b/packages/helpers-requests/src/index.ts @@ -58,12 +58,15 @@ export type { export * as webAuth from './web/auth.js'; export * as webBuckets from './web/buckets.js'; export * as webExchangeRates from './web/exchangeRates.js'; +export * as webProduct from './web/product.js'; export type { ListChildBucketsQuery, ListTopLevelBucketsQuery } from './web/buckets.js'; export * as managementWebAuth from './management-web/auth.js'; export * as managementWebAdmins from './management-web/admins.js'; export * as managementWebAdminRoles from './management-web/adminRoles.js'; export * as managementWebEvents from './management-web/events.js'; export * as managementWebUsers from './management-web/users.js'; +export * as managementWebProductMembership from './management-web/productMembership.js'; +export * as managementWebBillingPrices from './management-web/billingPrices.js'; export * as managementWebApps from './management-web/apps.js'; export * as managementWebBuckets from './management-web/buckets.js'; export * as managementWebBucketMessages from './management-web/bucketMessages.js'; @@ -102,6 +105,10 @@ export type { PredefinedBucketRoleItem, UpdateBucketRoleBody, } from './management-web/bucketRoles.js'; +export type { + BillingPriceAuditEntryDto, + BillingPriceWindowDto, +} from './management-web/billingPrices.js'; export type { CreateTermsVersionBody as CreateManagementTermsVersionBody, ManagementTermsVersion, diff --git a/packages/helpers-requests/src/management-web/adminRoles.ts b/packages/helpers-requests/src/management-web/adminRoles.ts index b2c73753..02fa2cb6 100644 --- a/packages/helpers-requests/src/management-web/adminRoles.ts +++ b/packages/helpers-requests/src/management-web/adminRoles.ts @@ -11,6 +11,7 @@ export type PredefinedManagementAdminRoleItem = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; isPredefined: true; createdAt: null; @@ -24,6 +25,7 @@ export type CustomManagementAdminRoleItem = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; isPredefined: false; createdAt: string; @@ -40,6 +42,7 @@ export type CreateManagementAdminRoleBody = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; }; @@ -50,6 +53,7 @@ export type UpdateManagementAdminRoleBody = { bucketsCrud?: number; bucketMessagesCrud?: number; bucketAdminsCrud?: number; + billingPricesCrud?: number; eventVisibility?: EventVisibility; }; diff --git a/packages/helpers-requests/src/management-web/billingPrices.ts b/packages/helpers-requests/src/management-web/billingPrices.ts new file mode 100644 index 00000000..b48706d5 --- /dev/null +++ b/packages/helpers-requests/src/management-web/billingPrices.ts @@ -0,0 +1,102 @@ +import type { ApiResponse } from '../request.js'; + +import { request } from '../request.js'; + +export type BillingPriceWindowStatus = 'active' | 'scheduled' | 'historical'; + +export type BillingPriceWindowDto = { + id: number; + billingProductId: number; + productCode: string; + currencyCode: string; + billingCadence: 'monthly' | 'annual'; + amountCents: number; + effectiveFrom: string; + effectiveTo: string | null; + source: string; + status: BillingPriceWindowStatus; +}; + +export type BillingPriceAuditEntryDto = { + id: number; + billingPriceId: number | null; + changedByManagementUserId: string | null; + changeReason: string | null; + previousAmountCents: number | null; + newAmountCents: number | null; + previousEffectiveFrom: string | null; + previousEffectiveTo: string | null; + newEffectiveFrom: string | null; + newEffectiveTo: string | null; + createdAt: string; + currencyCode: string | null; + billingCadence: 'monthly' | 'annual' | null; + productCode: string | null; +}; + +export type BillingPriceWindowsPayload = { + data: { + windows: BillingPriceWindowDto[]; + defaultCurrency: string; + }; +}; + +export type BillingPriceAuditPayload = { + data: { + entries: BillingPriceAuditEntryDto[]; + }; +}; + +export async function listBillingPriceWindows( + baseUrl: string, + token?: string | null +): Promise> { + return request(baseUrl, '/billing-prices/windows', { + token: token ?? undefined, + }); +} + +export async function listBillingPriceAudit( + baseUrl: string, + options?: { token?: string | null; limit?: number } +): Promise> { + const limit = options?.limit; + const path = + limit !== undefined && limit > 0 + ? `/billing-prices/audit?limit=${encodeURIComponent(String(limit))}` + : '/billing-prices/audit'; + return request(baseUrl, path, { + token: options?.token ?? undefined, + }); +} + +export async function scheduleBillingPriceChange( + baseUrl: string, + body: { + currencyCode: string; + billingCadence: 'monthly' | 'annual'; + amountCents: number; + effectiveFrom: string; + changeReason?: string | null; + }, + token?: string | null +): Promise> { + return request<{ data: { newPriceId: number } }>(baseUrl, '/billing-prices', { + method: 'POST', + body: JSON.stringify(body), + token: token ?? undefined, + }); +} + +export async function deprecateBillingPrice( + baseUrl: string, + priceId: number, + body: { changeReason?: string | null }, + token?: string | null +): Promise> { + return request(baseUrl, `/billing-prices/${priceId}/deprecate`, { + method: 'POST', + body: JSON.stringify(body), + token: token ?? undefined, + }); +} diff --git a/packages/helpers-requests/src/management-web/productMembership.ts b/packages/helpers-requests/src/management-web/productMembership.ts new file mode 100644 index 00000000..0abac94a --- /dev/null +++ b/packages/helpers-requests/src/management-web/productMembership.ts @@ -0,0 +1,18 @@ +import type { ApiResponse } from '../request.js'; +import type { ResolvedProductMembership } from '@metaboost/helpers'; + +import { request } from '../request.js'; + +export type ResolvedProductMembershipResponse = { + data: ResolvedProductMembership; +}; + +/** Requires `billingPrices` read permission (super admin bypasses); uses cookie auth when token omitted. */ +export async function getResolvedProductMembership( + baseUrl: string, + token?: string | null +): Promise> { + return request(baseUrl, '/product/membership', { + token: token ?? undefined, + }); +} diff --git a/packages/helpers-requests/src/types/billing-read-model-types.ts b/packages/helpers-requests/src/types/billing-read-model-types.ts new file mode 100644 index 00000000..0d2cff0f --- /dev/null +++ b/packages/helpers-requests/src/types/billing-read-model-types.ts @@ -0,0 +1,6 @@ +export type { + AuthenticatedBillingMembershipReadModelData, + BillingRenewalLastStatus, + PublicProductMembershipReadModelData, + BILLING_LIST_PRICE_CURRENCY_CODE, +} from '@metaboost/helpers'; diff --git a/packages/helpers-requests/src/types/index.ts b/packages/helpers-requests/src/types/index.ts index e63043bf..83ea95d9 100644 --- a/packages/helpers-requests/src/types/index.ts +++ b/packages/helpers-requests/src/types/index.ts @@ -34,6 +34,12 @@ export type { UpdateTermsVersionBody, } from './management-terms-types.js'; export type { BearerToken, WithOptionalToken } from './request-types.js'; +export type { + AuthenticatedBillingMembershipReadModelData, + BillingRenewalLastStatus, + BILLING_LIST_PRICE_CURRENCY_CODE, + PublicProductMembershipReadModelData, +} from './billing-read-model-types.js'; export type { MbrssV1ActionValue } from '@metaboost/helpers'; export type { Bucket, diff --git a/packages/helpers-requests/src/types/management-admin-types.ts b/packages/helpers-requests/src/types/management-admin-types.ts index 4458329b..9307582f 100644 --- a/packages/helpers-requests/src/types/management-admin-types.ts +++ b/packages/helpers-requests/src/types/management-admin-types.ts @@ -19,6 +19,7 @@ export interface PublicManagementUser { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; } | null; } @@ -44,6 +45,7 @@ export interface CreateAdminBody { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; } @@ -58,5 +60,6 @@ export interface UpdateAdminBody { bucketsCrud?: number; bucketMessagesCrud?: number; bucketAdminsCrud?: number; + billingPricesCrud?: number; eventVisibility?: EventVisibility; } diff --git a/packages/helpers-requests/src/types/management-user-types.ts b/packages/helpers-requests/src/types/management-user-types.ts index 68558e10..01e84962 100644 --- a/packages/helpers-requests/src/types/management-user-types.ts +++ b/packages/helpers-requests/src/types/management-user-types.ts @@ -1,3 +1,5 @@ +import type { PremiumBillingCadence } from '@metaboost/helpers'; + /** Main-app user as returned by GET /users and GET /users/:id (safe, no credentials). */ export interface PublicMainAppUser { id: string; @@ -29,6 +31,8 @@ export interface CreateUserBody { membershipTier?: 'trial' | 'premium'; membershipExpiresAt?: string | null; autoRenew?: boolean; + /** When membership is premium and expiry omitted: monthly ⇒ +1 month, annual ⇒ +12 months from now. */ + premiumBillingCadence?: PremiumBillingCadence; } /** Validated body for PATCH /users/:id. At least one field present. */ diff --git a/packages/helpers-requests/src/web/product.ts b/packages/helpers-requests/src/web/product.ts new file mode 100644 index 00000000..02f5b4a2 --- /dev/null +++ b/packages/helpers-requests/src/web/product.ts @@ -0,0 +1,34 @@ +import type { ApiResponse } from '../request.js'; +import type { + AuthenticatedBillingMembershipReadModelData, + PublicProductMembershipReadModelData, +} from '../types/billing-read-model-types.js'; + +import { request } from '../request.js'; + +export type PublicProductMembershipPayload = { + data: PublicProductMembershipReadModelData; +}; + +export type AuthenticatedBillingMembershipPayload = { + data: AuthenticatedBillingMembershipReadModelData; +}; + +/** GET /product/membership — public resolved pricing/trial and signup visibility (no auth). */ +export async function getPublicProductMembership( + baseUrl: string +): Promise> { + return request(baseUrl, '/product/membership'); +} + +/** GET /auth/billing/membership-summary — session cookie or Bearer token. */ +export async function getBillingMembershipSummary( + baseUrl: string, + token?: string | null +): Promise> { + return request( + baseUrl, + '/auth/billing/membership-summary', + { token: token ?? undefined } + ); +} diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 2d919f15..4d5e30d5 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -5,6 +5,13 @@ "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "scripts": { "build": "tsc", "build:watch": "tsc --watch --preserveWatchOutput", diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 1e748ec1..9f6794f7 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -171,6 +171,42 @@ export { isFinitePositive } from './number/isFinitePositive.js'; export { isNonNegativeInteger } from './number/isNonNegativeInteger.js'; export { compareStringsEmptyLastLexicographic } from './sort/compareStringsEmptyLastLexicographic.js'; export { MembershipTier, MEMBERSHIP_TIER_VALUES } from './trust/constants.js'; +export { + DEFAULT_FREE_TRIAL_EXPIRATION, + PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK, + resolveProductMembershipDefaultsFromEnv, + type ProductMembershipDefaultsFromEnv, +} from './membership/productMembershipDefaultsFromEnv.js'; +export type { PremiumBillingCadence } from './membership/premiumBillingCadence.js'; +export type { + BillingCadence, + BillingProductCode, + MembershipPeriodExtensionReason, +} from './membership/billingDomain.js'; +export { BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM } from './membership/billingDomain.js'; +export { + BILLING_DOMAIN_EVENT_TYPES, + type BillingDomainEventType, +} from './membership/billingDomainEvents.js'; +export type { + BillingRenewalAttemptResult, + BillingRenewalProviderAdapter, +} from './membership/billingRenewalAdapter.js'; +export { createStubBillingRenewalProviderAdapter } from './membership/billingRenewalAdapter.js'; +export type { ResolvedProductMembership } from './membership/resolvedProductMembership.js'; +export { + BILLING_LIST_PRICE_CURRENCY_CODE, + type AuthenticatedBillingMembershipReadModelData, + type BillingRenewalLastStatus, + type PublicProductMembershipReadModelData, +} from './membership/billingReadModelTypes.js'; +export { + extendMembershipPeriodByCadence, + extendMembershipPeriodByMonths, + membershipExtensionBaseDate, + monthsForPremiumCadence, + resolveInitialMembershipExpiresAt, +} from './membership/membershipPeriodPolicy.js'; export type { AccountEntitlementCapability } from './trust/accountEntitlementCapability.js'; export { ACCOUNT_ENTITLEMENT_CAPABILITY } from './trust/accountEntitlementCapability.js'; export { membershipTierFromStoredValue } from './trust/membershipTierFromStoredValue.js'; diff --git a/packages/helpers/src/managementAdminRoles/constants.ts b/packages/helpers/src/managementAdminRoles/constants.ts index ae77855f..3b095572 100644 --- a/packages/helpers/src/managementAdminRoles/constants.ts +++ b/packages/helpers/src/managementAdminRoles/constants.ts @@ -18,6 +18,7 @@ export type PredefinedManagementAdminRole = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; }; @@ -30,6 +31,7 @@ export const PREDEFINED_MANAGEMENT_ADMIN_ROLES: PredefinedManagementAdminRole[] bucketsCrud: 15, bucketMessagesCrud: 15, bucketAdminsCrud: 15, + billingPricesCrud: 15, eventVisibility: 'all', }, { @@ -40,6 +42,7 @@ export const PREDEFINED_MANAGEMENT_ADMIN_ROLES: PredefinedManagementAdminRole[] bucketsCrud: 2, bucketMessagesCrud: 2, bucketAdminsCrud: 2, + billingPricesCrud: 2, eventVisibility: 'all_admins', }, { @@ -50,6 +53,7 @@ export const PREDEFINED_MANAGEMENT_ADMIN_ROLES: PredefinedManagementAdminRole[] bucketsCrud: 15, bucketMessagesCrud: 15, bucketAdminsCrud: 15, + billingPricesCrud: 15, eventVisibility: 'all_admins', }, { @@ -60,6 +64,7 @@ export const PREDEFINED_MANAGEMENT_ADMIN_ROLES: PredefinedManagementAdminRole[] bucketsCrud: 15, bucketMessagesCrud: 15, bucketAdminsCrud: 15, + billingPricesCrud: 0, eventVisibility: 'all_admins', }, { @@ -70,6 +75,7 @@ export const PREDEFINED_MANAGEMENT_ADMIN_ROLES: PredefinedManagementAdminRole[] bucketsCrud: 2, bucketMessagesCrud: 2, bucketAdminsCrud: 2, + billingPricesCrud: 0, eventVisibility: 'all_admins', }, ]; diff --git a/packages/helpers/src/membership/billingDomain.ts b/packages/helpers/src/membership/billingDomain.ts new file mode 100644 index 00000000..b8d1df0b --- /dev/null +++ b/packages/helpers/src/membership/billingDomain.ts @@ -0,0 +1,20 @@ +import type { PremiumBillingCadence } from './premiumBillingCadence.js'; + +/** + * Vendor-agnostic cadence label for premium membership period math. + * Alias of {@link PremiumBillingCadence} for billing catalog alignment. + */ +export type BillingCadence = PremiumBillingCadence; + +export const BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM = 'membership_premium' as const; + +export type BillingProductCode = typeof BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM; + +/** Reasons for membership period mutations (orchestration and audit contracts). */ +export type MembershipPeriodExtensionReason = + | 'initial_default' + | 'tier_change_default' + | 'payment_settled' + | 'pay_on_demand_extension' + | 'renewal_success' + | 'admin_adjustment'; diff --git a/packages/helpers/src/membership/billingDomainEvents.ts b/packages/helpers/src/membership/billingDomainEvents.ts new file mode 100644 index 00000000..2c59847e --- /dev/null +++ b/packages/helpers/src/membership/billingDomainEvents.ts @@ -0,0 +1,12 @@ +/** + * Normalized billing domain event labels stored in `billing_domain_event.event_type`. + */ +export const BILLING_DOMAIN_EVENT_TYPES = { + PAYMENT_SETTLED: 'payment_settled', + RENEWAL_SUCCEEDED: 'renewal_succeeded', + RENEWAL_FAILED: 'renewal_failed', + PAY_ON_DEMAND_EXTENSION_REQUESTED: 'pay_on_demand_extension_requested', +} as const; + +export type BillingDomainEventType = + (typeof BILLING_DOMAIN_EVENT_TYPES)[keyof typeof BILLING_DOMAIN_EVENT_TYPES]; diff --git a/packages/helpers/src/membership/billingReadModelTypes.ts b/packages/helpers/src/membership/billingReadModelTypes.ts new file mode 100644 index 00000000..8975688a --- /dev/null +++ b/packages/helpers/src/membership/billingReadModelTypes.ts @@ -0,0 +1,46 @@ +import type { ResolvedProductMembership } from './resolvedProductMembership.js'; + +/** + * ISO 4217 currency code for list-priced premium tiers returned by public billing read models. + * Catalog rows currently store USD; clients must not assume user-settled currency for upgrades. + */ +export const BILLING_LIST_PRICE_CURRENCY_CODE = 'USD' as const; + +/** + * Public (unauthenticated) product/membership read model for marketing and signup flows. + * Resolved DB-first on the server; env seeds gaps only. Same numeric fields as + * `ResolvedProductMembership`, flattened under `data` (Podverse parity). + */ +export type PublicProductMembershipReadModelData = ResolvedProductMembership & { + listPriceCurrencyCode: typeof BILLING_LIST_PRICE_CURRENCY_CODE; + /** + * Mirrors account signup mode: when false, anonymous clients must not treat premium list prices + * as actionable for self-serve signup (admin provisioning only). + */ + selfServePublicSignupOpen: boolean; +}; + +export type BillingRenewalLastStatus = 'none' | 'succeeded' | 'failed'; + +/** + * Authenticated user billing snapshot plus resolved catalog pricing (no PII). + */ +export type AuthenticatedBillingMembershipReadModelData = { + listPriceCurrencyCode: typeof BILLING_LIST_PRICE_CURRENCY_CODE; + membership: { + tier: string; + /** ISO 8601 UTC instant; null when unset. */ + expiresAtIso: string | null; + premiumBillingCadence: 'monthly' | 'annual' | null; + autoRenewMode: 'off' | 'on'; + }; + renewal: { + lastStatus: BillingRenewalLastStatus; + /** ISO 8601 UTC instant; null when never attempted. */ + lastAttemptAtIso: string | null; + /** ISO 8601 UTC instant; null when no retry scheduled. */ + nextAttemptAtIso: string | null; + retryCount: number; + }; + catalog: ResolvedProductMembership; +}; diff --git a/packages/helpers/src/membership/billingRenewalAdapter.ts b/packages/helpers/src/membership/billingRenewalAdapter.ts new file mode 100644 index 00000000..62e0fd43 --- /dev/null +++ b/packages/helpers/src/membership/billingRenewalAdapter.ts @@ -0,0 +1,25 @@ +import type { BillingCadence } from './billingDomain.js'; + +export type BillingRenewalAttemptResult = + | { status: 'succeeded'; providerAttemptId?: string } + | { status: 'failed'; providerAttemptId?: string; retryAfterSeconds?: number }; + +/** + * Provider-agnostic renewal boundary (Stripe, internal ledger, etc.). + * Implementations must treat `idempotencyKey` as a dedupe key for charge attempts. + */ +export interface BillingRenewalProviderAdapter { + attemptRenewal(params: { + userId: string; + cadence: BillingCadence; + idempotencyKey: string; + }): Promise; +} + +export function createStubBillingRenewalProviderAdapter(): BillingRenewalProviderAdapter { + return { + async attemptRenewal() { + return { status: 'succeeded', providerAttemptId: 'stub' }; + }, + }; +} diff --git a/packages/helpers/src/membership/membershipPeriodPolicy.test.ts b/packages/helpers/src/membership/membershipPeriodPolicy.test.ts new file mode 100644 index 00000000..d471ef2a --- /dev/null +++ b/packages/helpers/src/membership/membershipPeriodPolicy.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { MembershipTier } from '../trust/constants.js'; +import { + extendMembershipPeriodByCadence, + extendMembershipPeriodByMonths, + membershipExtensionBaseDate, + monthsForPremiumCadence, + resolveInitialMembershipExpiresAt, +} from './membershipPeriodPolicy.js'; + +describe('membershipPeriodPolicy', () => { + describe('monthsForPremiumCadence', () => { + it('maps monthly and annual', () => { + expect(monthsForPremiumCadence('monthly')).toBe(1); + expect(monthsForPremiumCadence('annual')).toBe(12); + }); + }); + + describe('membershipExtensionBaseDate', () => { + it('uses now when expiry is null', () => { + const now = new Date(2026, 4, 5, 12, 0, 0); + expect(membershipExtensionBaseDate(null, now).getTime()).toBe(now.getTime()); + }); + + it('uses now when expiry is in the past', () => { + const now = new Date(2026, 4, 5, 12, 0, 0); + const past = new Date(2025, 0, 1); + expect(membershipExtensionBaseDate(past, now).getTime()).toBe(now.getTime()); + }); + + it('uses existing expiry when still active', () => { + const now = new Date(2026, 4, 5, 12, 0, 0); + const future = new Date(2027, 0, 1); + expect(membershipExtensionBaseDate(future, now).getTime()).toBe(future.getTime()); + }); + }); + + describe('extendMembershipPeriodByCadence', () => { + it('stacks monthly from active expiry', () => { + const now = new Date(2026, 4, 5, 12, 0, 0); + const expiry = new Date(2026, 5, 10); + const next = extendMembershipPeriodByCadence({ + membershipExpiresAt: expiry, + cadence: 'monthly', + now, + }); + expect(next.getFullYear()).toBe(2026); + expect(next.getMonth()).toBe(6); + expect(next.getDate()).toBe(10); + }); + + it('Jan 31 + 1 month clamps to Feb end (leap year)', () => { + const now = new Date(2024, 0, 15); + const jan31 = new Date(2024, 0, 31); + const next = extendMembershipPeriodByCadence({ + membershipExpiresAt: jan31, + cadence: 'monthly', + now, + }); + expect(next.getFullYear()).toBe(2024); + expect(next.getMonth()).toBe(1); + expect(next.getDate()).toBe(29); + }); + + it('Mar 31 + 1 month clamps to Apr 30', () => { + const now = new Date(2026, 2, 15); + const mar31 = new Date(2026, 2, 31); + const next = extendMembershipPeriodByCadence({ + membershipExpiresAt: mar31, + cadence: 'monthly', + now, + }); + expect(next.getFullYear()).toBe(2026); + expect(next.getMonth()).toBe(3); + expect(next.getDate()).toBe(30); + }); + }); + + describe('extendMembershipPeriodByMonths', () => { + it('adds arbitrary months from extension base', () => { + const now = new Date(2026, 4, 5); + const next = extendMembershipPeriodByMonths({ + membershipExpiresAt: null, + monthsToAdd: 3, + now, + }); + expect(next.getFullYear()).toBe(2026); + expect(next.getMonth()).toBe(7); + expect(next.getDate()).toBe(5); + }); + }); + + describe('resolveInitialMembershipExpiresAt', () => { + it('premium monthly adds one calendar month from now', () => { + const now = new Date(2026, 0, 15, 10, 0, 0); + const d = resolveInitialMembershipExpiresAt({ + membershipTier: MembershipTier.Premium, + premiumBillingCadence: 'monthly', + trialExpirationSeconds: 86400, + now, + }); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(1); + expect(d.getDate()).toBe(15); + }); + + it('premium defaults to annual cadence when omitted', () => { + const now = new Date(2026, 0, 15); + const d = resolveInitialMembershipExpiresAt({ + membershipTier: MembershipTier.Premium, + trialExpirationSeconds: 1, + now, + }); + expect(d.getFullYear()).toBe(2027); + expect(d.getMonth()).toBe(0); + expect(d.getDate()).toBe(15); + }); + + it('trial adds seconds', () => { + const now = new Date(2026, 4, 5, 12, 0, 0); + const d = resolveInitialMembershipExpiresAt({ + membershipTier: MembershipTier.Trial, + trialExpirationSeconds: 3600, + now, + }); + expect(d.getTime()).toBe(now.getTime() + 3600 * 1000); + }); + }); +}); diff --git a/packages/helpers/src/membership/membershipPeriodPolicy.ts b/packages/helpers/src/membership/membershipPeriodPolicy.ts new file mode 100644 index 00000000..51f880c8 --- /dev/null +++ b/packages/helpers/src/membership/membershipPeriodPolicy.ts @@ -0,0 +1,65 @@ +import type { BillingCadence } from './billingDomain.js'; +import type { PremiumBillingCadence } from './premiumBillingCadence.js'; + +import { addMonths } from '../time/addMonths.js'; +import { MembershipTier } from '../trust/constants.js'; + +/** + * Whole months to add for a premium cadence (calendar-month clamp via {@link addMonths}). + */ +export function monthsForPremiumCadence(cadence: BillingCadence): number { + return cadence === 'monthly' ? 1 : 12; +} + +/** + * Base instant for stacking the next period: extend from current expiry while active, + * otherwise from `now` when expired or unset. + */ +export function membershipExtensionBaseDate( + membershipExpiresAt: Date | null | undefined, + now: Date +): Date { + if (membershipExpiresAt === null || membershipExpiresAt === undefined) { + return now; + } + return membershipExpiresAt.getTime() > now.getTime() ? membershipExpiresAt : now; +} + +export function extendMembershipPeriodByCadence(params: { + membershipExpiresAt: Date | null | undefined; + cadence: BillingCadence; + now?: Date; +}): Date { + const now = params.now ?? new Date(); + const base = membershipExtensionBaseDate(params.membershipExpiresAt, now); + return addMonths(base, monthsForPremiumCadence(params.cadence)); +} + +export function extendMembershipPeriodByMonths(params: { + membershipExpiresAt: Date | null | undefined; + monthsToAdd: number; + now?: Date; +}): Date { + const now = params.now ?? new Date(); + const base = membershipExtensionBaseDate(params.membershipExpiresAt, now); + return addMonths(base, params.monthsToAdd); +} + +/** + * Default expiry for a newly created trust settings row when the caller does not pass an explicit + * `membershipExpiresAt`. Trial: `now + trialExpirationSeconds`. Premium: `now` plus cadence months + * (defaults to annual when cadence is omitted). + */ +export function resolveInitialMembershipExpiresAt(params: { + membershipTier: MembershipTier; + premiumBillingCadence?: PremiumBillingCadence; + trialExpirationSeconds: number; + now?: Date; +}): Date { + const now = params.now ?? new Date(); + if (params.membershipTier === MembershipTier.Premium) { + const cadence = params.premiumBillingCadence ?? 'annual'; + return addMonths(now, monthsForPremiumCadence(cadence)); + } + return new Date(now.getTime() + params.trialExpirationSeconds * 1000); +} diff --git a/packages/helpers/src/membership/premiumBillingCadence.ts b/packages/helpers/src/membership/premiumBillingCadence.ts new file mode 100644 index 00000000..5c59248f --- /dev/null +++ b/packages/helpers/src/membership/premiumBillingCadence.ts @@ -0,0 +1,4 @@ +/** + * Billing period for premium membership when deriving default expiry from cadence (monthly vs annual). + */ +export type PremiumBillingCadence = 'monthly' | 'annual'; diff --git a/packages/helpers/src/membership/productMembershipDefaultsFromEnv.ts b/packages/helpers/src/membership/productMembershipDefaultsFromEnv.ts new file mode 100644 index 00000000..dbd94b6e --- /dev/null +++ b/packages/helpers/src/membership/productMembershipDefaultsFromEnv.ts @@ -0,0 +1,66 @@ +/** + * Env-only fallbacks for membership product defaults (same keys as API/management-api startup validation). + * Lenient parsing: invalid optional env falls back to defaults (safe for HTTP handlers). + * + * Runtime resolution is DB-first: `BillingPriceCatalogService.resolveProductMembership` and + * `ProductMembershipSettingsService` read `product_membership_settings` and active premium prices; + * these env values apply when inserting missing rows only (`ON CONFLICT DO NOTHING` / gap-fill pricing). + */ + +/** Default trial duration when `MEMBERSHIP_FREE_TRIAL_EXPIRATION` is unset (seconds; see env-expiration-naming skill). */ +export const DEFAULT_FREE_TRIAL_EXPIRATION = 31 * 24 * 60 * 60; + +export type ProductMembershipDefaultsFromEnv = { + freeTrialExpirationSeconds: number; + premiumMembershipCostMonthly: number; + premiumMembershipCostAnnually: number; +}; + +/** Defaults when env vars are unset; shared by `resolveProductMembershipDefaultsFromEnv` and browser fallbacks. */ +export const PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK: ProductMembershipDefaultsFromEnv = { + freeTrialExpirationSeconds: DEFAULT_FREE_TRIAL_EXPIRATION, + premiumMembershipCostMonthly: 3, + premiumMembershipCostAnnually: 30, +}; + +function parseNonNegativeNumberEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') { + return fallback; + } + const n = Number(String(raw).trim()); + if (!Number.isFinite(n) || n < 0) { + return fallback; + } + return n; +} + +function parsePositiveIntegerTrialExpiration(name: string, fallbackExpiration: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === null || String(raw).trim() === '') { + return fallbackExpiration; + } + const n = Number(String(raw).trim()); + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) { + return fallbackExpiration; + } + return n; +} + +/** Non-secret product defaults for admin UIs and optional main-app mirrors. */ +export function resolveProductMembershipDefaultsFromEnv(): ProductMembershipDefaultsFromEnv { + return { + freeTrialExpirationSeconds: parsePositiveIntegerTrialExpiration( + 'MEMBERSHIP_FREE_TRIAL_EXPIRATION', + PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK.freeTrialExpirationSeconds + ), + premiumMembershipCostMonthly: parseNonNegativeNumberEnv( + 'MEMBERSHIP_PREMIUM_COST_MONTHLY', + PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK.premiumMembershipCostMonthly + ), + premiumMembershipCostAnnually: parseNonNegativeNumberEnv( + 'MEMBERSHIP_PREMIUM_COST_ANNUALLY', + PRODUCT_MEMBERSHIP_DEFAULTS_FROM_ENV_FALLBACK.premiumMembershipCostAnnually + ), + }; +} diff --git a/packages/helpers/src/membership/resolvedProductMembership.ts b/packages/helpers/src/membership/resolvedProductMembership.ts new file mode 100644 index 00000000..1a259335 --- /dev/null +++ b/packages/helpers/src/membership/resolvedProductMembership.ts @@ -0,0 +1,7 @@ +import type { ProductMembershipDefaultsFromEnv } from './productMembershipDefaultsFromEnv.js'; + +/** + * Resolved premium membership marketing values (trial length and USD prices), merged from DB + * catalog and env bootstrap. + */ +export type ResolvedProductMembership = ProductMembershipDefaultsFromEnv; diff --git a/packages/helpers/src/time/addMonths.test.ts b/packages/helpers/src/time/addMonths.test.ts index 88e377d1..2232e557 100644 --- a/packages/helpers/src/time/addMonths.test.ts +++ b/packages/helpers/src/time/addMonths.test.ts @@ -10,4 +10,20 @@ describe('addMonths', () => { expect(next.getMonth()).toBe(3); expect(next.getDate()).toBe(15); }); + + it('clamps Jan 31 to last day of February in a leap year', () => { + const start = new Date(2024, 0, 31); + const next = addMonths(start, 1); + expect(next.getFullYear()).toBe(2024); + expect(next.getMonth()).toBe(1); + expect(next.getDate()).toBe(29); + }); + + it('clamps Mar 31 to Apr 30', () => { + const start = new Date(2026, 2, 31); + const next = addMonths(start, 1); + expect(next.getFullYear()).toBe(2026); + expect(next.getMonth()).toBe(3); + expect(next.getDate()).toBe(30); + }); }); diff --git a/packages/helpers/src/time/addMonths.ts b/packages/helpers/src/time/addMonths.ts index 51a3facd..c77b04ff 100644 --- a/packages/helpers/src/time/addMonths.ts +++ b/packages/helpers/src/time/addMonths.ts @@ -1,8 +1,22 @@ /** - * Calendar-month addition (native `Date#setMonth` semantics, including end-of-month rollover). + * Calendar-month addition with day clamping to the last valid day of the target month + * (e.g. Jan 31 + 1 month → Feb 29 in a leap year, Feb 28 otherwise). */ export function addMonths(baseDate: Date, months: number): Date { - const date = new Date(baseDate); - date.setMonth(date.getMonth() + months); - return date; + const year = baseDate.getFullYear(); + const month = baseDate.getMonth(); + const day = baseDate.getDate(); + const hours = baseDate.getHours(); + const minutes = baseDate.getMinutes(); + const seconds = baseDate.getSeconds(); + const milliseconds = baseDate.getMilliseconds(); + + const targetMonthIndex = month + months; + const targetYear = year + Math.floor(targetMonthIndex / 12); + const normalizedMonth = ((targetMonthIndex % 12) + 12) % 12; + + const lastDayInTargetMonth = new Date(targetYear, normalizedMonth + 1, 0).getDate(); + const clampedDay = Math.min(day, lastDayInTargetMonth); + + return new Date(targetYear, normalizedMonth, clampedDay, hours, minutes, seconds, milliseconds); } diff --git a/packages/helpers/src/time/toDateTimeLocalValue.test.ts b/packages/helpers/src/time/toDateTimeLocalValue.test.ts new file mode 100644 index 00000000..be3404d7 --- /dev/null +++ b/packages/helpers/src/time/toDateTimeLocalValue.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { toDateTimeLocalValue } from './toDateTimeLocalValue.js'; + +describe('toDateTimeLocalValue', () => { + it('returns empty string for null and undefined', () => { + expect(toDateTimeLocalValue(null)).toBe(''); + expect(toDateTimeLocalValue(undefined)).toBe(''); + }); + + it('returns empty string for invalid date input', () => { + expect(toDateTimeLocalValue('not-a-date')).toBe(''); + }); + + it('formats a local-constructed Date using local calendar fields', () => { + const d = new Date(2020, 6, 8, 9, 7); + expect(toDateTimeLocalValue(d)).toBe('2020-07-08T09:07'); + }); + + it('formats numeric timestamps', () => { + const d = new Date(2021, 0, 2, 3, 4); + expect(toDateTimeLocalValue(d.getTime())).toBe('2021-01-02T03:04'); + }); + + it('matches formatting from Date from the same parseable string', () => { + const s = '2020-06-15T14:30:00'; + expect(toDateTimeLocalValue(s)).toBe(toDateTimeLocalValue(new Date(s))); + }); +}); diff --git a/packages/helpers/src/time/toDateTimeLocalValue.ts b/packages/helpers/src/time/toDateTimeLocalValue.ts index f03f1f49..ba4746dd 100644 --- a/packages/helpers/src/time/toDateTimeLocalValue.ts +++ b/packages/helpers/src/time/toDateTimeLocalValue.ts @@ -1,12 +1,17 @@ -export function toDateTimeLocalValue(value: string | Date | null | undefined): string { +/** + * Format a value for `` using the environment's + * local calendar fields (not UTC). Matches Podverse `toDatetimeLocalInputValue`. + */ +export function toDateTimeLocalValue(value: Date | number | string | null | undefined): string { if (value === null || value === undefined) { return ''; } - const parsed = value instanceof Date ? value : new Date(value); - if (Number.isNaN(parsed.getTime())) { + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) { return ''; } - return parsed.toISOString().slice(0, 16); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } diff --git a/packages/management-orm/src/entities/AdminPermissions.ts b/packages/management-orm/src/entities/AdminPermissions.ts index c508a39d..c2363397 100644 --- a/packages/management-orm/src/entities/AdminPermissions.ts +++ b/packages/management-orm/src/entities/AdminPermissions.ts @@ -33,6 +33,9 @@ export class AdminPermissions { @Column({ name: 'bucket_admins_crud', type: 'integer', default: 0 }) bucketAdminsCrud!: number; + @Column({ name: 'billing_prices_crud', type: 'integer', default: 0 }) + billingPricesCrud!: number; + @Column({ name: 'event_visibility', type: 'text', default: 'all_admins' }) eventVisibility!: EventVisibility; diff --git a/packages/management-orm/src/entities/ManagementAdminRole.ts b/packages/management-orm/src/entities/ManagementAdminRole.ts index 605d86ce..ddafe0d8 100644 --- a/packages/management-orm/src/entities/ManagementAdminRole.ts +++ b/packages/management-orm/src/entities/ManagementAdminRole.ts @@ -25,6 +25,9 @@ export class ManagementAdminRole { @Column({ name: 'bucket_admins_crud', type: 'integer' }) bucketAdminsCrud!: number; + @Column({ name: 'billing_prices_crud', type: 'integer' }) + billingPricesCrud!: number; + @Column({ name: 'event_visibility', type: 'text' }) eventVisibility!: EventVisibility; diff --git a/packages/management-orm/src/services/ManagementAdminRoleService.ts b/packages/management-orm/src/services/ManagementAdminRoleService.ts index d10ef6d1..008fc5d9 100644 --- a/packages/management-orm/src/services/ManagementAdminRoleService.ts +++ b/packages/management-orm/src/services/ManagementAdminRoleService.ts @@ -10,6 +10,7 @@ export type CreateManagementAdminRoleData = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; }; @@ -20,6 +21,7 @@ export type UpdateManagementAdminRoleData = { bucketsCrud?: number; bucketMessagesCrud?: number; bucketAdminsCrud?: number; + billingPricesCrud?: number; eventVisibility?: EventVisibility; }; diff --git a/packages/management-orm/src/services/ManagementUserService.ts b/packages/management-orm/src/services/ManagementUserService.ts index fcecec47..cce11f5e 100644 --- a/packages/management-orm/src/services/ManagementUserService.ts +++ b/packages/management-orm/src/services/ManagementUserService.ts @@ -19,6 +19,7 @@ export type CreateAdminData = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; eventVisibility: EventVisibility; }; @@ -31,6 +32,7 @@ export type UpdateAdminData = { bucketsCrud?: number; bucketMessagesCrud?: number; bucketAdminsCrud?: number; + billingPricesCrud?: number; eventVisibility?: EventVisibility; }; @@ -201,6 +203,7 @@ export class ManagementUserService { bucketsCrud: data.bucketsCrud, bucketMessagesCrud: data.bucketMessagesCrud, bucketAdminsCrud: data.bucketAdminsCrud, + billingPricesCrud: data.billingPricesCrud, eventVisibility: data.eventVisibility, }); await permRepo.save(perm); @@ -250,6 +253,7 @@ export class ManagementUserService { bucketsCrud?: number; bucketMessagesCrud?: number; bucketAdminsCrud?: number; + billingPricesCrud?: number; eventVisibility?: EventVisibility; } = {}; if (data.adminsCrud !== undefined) updates.adminsCrud = data.adminsCrud; @@ -258,6 +262,8 @@ export class ManagementUserService { if (data.bucketMessagesCrud !== undefined) updates.bucketMessagesCrud = data.bucketMessagesCrud; if (data.bucketAdminsCrud !== undefined) updates.bucketAdminsCrud = data.bucketAdminsCrud; + if (data.billingPricesCrud !== undefined) + updates.billingPricesCrud = data.billingPricesCrud; if (data.eventVisibility !== undefined) updates.eventVisibility = data.eventVisibility; if (Object.keys(updates).length > 0) { await permRepo.update({ adminId: id }, updates); diff --git a/packages/orm/src/data-source.ts b/packages/orm/src/data-source.ts index 2bbe7054..0e97fc30 100644 --- a/packages/orm/src/data-source.ts +++ b/packages/orm/src/data-source.ts @@ -7,6 +7,9 @@ import type { DataSourceOptions } from 'typeorm'; */ import { DataSource } from 'typeorm'; +import { BillingDomainEvent } from './entities/BillingDomainEvent.js'; +import { BillingPrice } from './entities/BillingPrice.js'; +import { BillingProduct } from './entities/BillingProduct.js'; import { Bucket } from './entities/Bucket.js'; import { BucketAdmin } from './entities/BucketAdmin.js'; import { BucketAdminInvitation } from './entities/BucketAdminInvitation.js'; @@ -20,6 +23,7 @@ import { BucketRSSChannelInfo } from './entities/BucketRSSChannelInfo.js'; import { BucketRSSItemInfo } from './entities/BucketRSSItemInfo.js'; import { BucketSettings } from './entities/BucketSettings.js'; import { GlobalBlockedApp } from './entities/GlobalBlockedApp.js'; +import { ProductMembershipSettings } from './entities/ProductMembershipSettings.js'; import { RefreshToken } from './entities/RefreshToken.js'; import { TermsVersion, TermsVersionContent } from './entities/TermsVersion.js'; import { User } from './entities/User.js'; @@ -34,6 +38,10 @@ const ENTITIES = [ UserCredentials, UserBio, UserTrustSettings, + BillingProduct, + BillingPrice, + BillingDomainEvent, + ProductMembershipSettings, TermsVersion, TermsVersionContent, UserTermsAcceptance, diff --git a/packages/orm/src/entities/BillingDomainEvent.ts b/packages/orm/src/entities/BillingDomainEvent.ts new file mode 100644 index 00000000..21997d1f --- /dev/null +++ b/packages/orm/src/entities/BillingDomainEvent.ts @@ -0,0 +1,24 @@ +import type { BillingDomainEventType } from '@metaboost/helpers'; + +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('billing_domain_event') +export class BillingDomainEvent { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'event_type', type: 'varchar', length: 48 }) + eventType!: BillingDomainEventType; + + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'idempotency_key', type: 'varchar', length: 128, nullable: true }) + idempotencyKey!: string | null; + + @Column({ type: 'jsonb' }) + payload!: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/packages/orm/src/entities/BillingPrice.ts b/packages/orm/src/entities/BillingPrice.ts new file mode 100644 index 00000000..58ec8784 --- /dev/null +++ b/packages/orm/src/entities/BillingPrice.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { BillingProduct } from './BillingProduct.js'; + +@Entity('billing_price') +export class BillingPrice { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'billing_product_id', type: 'integer' }) + billingProductId!: number; + + @ManyToOne(() => BillingProduct, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'billing_product_id' }) + billingProduct!: BillingProduct; + + @Column({ name: 'currency_code', type: 'char', length: 3 }) + currencyCode!: string; + + @Column({ name: 'billing_cadence', type: 'varchar', length: 16 }) + billingCadence!: 'monthly' | 'annual'; + + @Column({ name: 'amount_cents', type: 'integer' }) + amountCents!: number; + + @Column({ name: 'effective_from', type: 'timestamp' }) + effectiveFrom!: Date; + + @Column({ name: 'effective_to', type: 'timestamp', nullable: true }) + effectiveTo!: Date | null; + + @Column({ type: 'varchar', length: 32, default: 'manual' }) + source!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/orm/src/entities/BillingProduct.ts b/packages/orm/src/entities/BillingProduct.ts new file mode 100644 index 00000000..1bc28595 --- /dev/null +++ b/packages/orm/src/entities/BillingProduct.ts @@ -0,0 +1,28 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('billing_product') +export class BillingProduct { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'product_code', type: 'text', unique: true }) + productCode!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/orm/src/entities/ProductMembershipSettings.ts b/packages/orm/src/entities/ProductMembershipSettings.ts new file mode 100644 index 00000000..856be3eb --- /dev/null +++ b/packages/orm/src/entities/ProductMembershipSettings.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('product_membership_settings') +export class ProductMembershipSettings { + @PrimaryColumn({ type: 'integer', default: 1 }) + id!: number; + + @Column({ name: 'free_trial_expiration_seconds', type: 'integer' }) + freeTrialExpirationSeconds!: number; + + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/orm/src/entities/UserTrustSettings.ts b/packages/orm/src/entities/UserTrustSettings.ts index 43fe1c07..e80ceb90 100644 --- a/packages/orm/src/entities/UserTrustSettings.ts +++ b/packages/orm/src/entities/UserTrustSettings.ts @@ -16,6 +16,33 @@ export class UserTrustSettings { @Column({ name: 'auto_renew', type: 'boolean', default: false }) autoRenew!: boolean; + @Column({ name: 'billing_cadence', type: 'varchar', length: 16, nullable: true }) + billingCadence!: string | null; + + @Column({ name: 'auto_renew_mode', type: 'varchar', length: 16, default: 'off' }) + autoRenewMode!: 'off' | 'on'; + + @Column({ name: 'next_renewal_attempt_at', type: 'timestamp', nullable: true }) + nextRenewalAttemptAt!: Date | null; + + @Column({ name: 'last_renewal_attempt_at', type: 'timestamp', nullable: true }) + lastRenewalAttemptAt!: Date | null; + + @Column({ name: 'last_renewal_status', type: 'varchar', length: 16, default: 'none' }) + lastRenewalStatus!: string; + + @Column({ name: 'last_extension_idempotency_key', type: 'varchar', length: 128, nullable: true }) + lastExtensionIdempotencyKey!: string | null; + + @Column({ name: 'last_renewal_idempotency_key', type: 'varchar', length: 128, nullable: true }) + lastRenewalIdempotencyKey!: string | null; + + @Column({ name: 'renewal_retry_count', type: 'integer', default: 0 }) + renewalRetryCount!: number; + + @Column({ name: 'renewal_retry_backoff_until', type: 'timestamp', nullable: true }) + renewalRetryBackoffUntil!: Date | null; + @Column({ name: 'created_at', type: 'timestamp' }) createdAt!: Date; diff --git a/packages/orm/src/index.ts b/packages/orm/src/index.ts index 8146b396..2db39fdc 100644 --- a/packages/orm/src/index.ts +++ b/packages/orm/src/index.ts @@ -27,6 +27,13 @@ export { BucketRSSChannelInfo } from './entities/BucketRSSChannelInfo.js'; export { BucketRSSItemInfo } from './entities/BucketRSSItemInfo.js'; export { BucketRole } from './entities/BucketRole.js'; export type { UserWithRelations } from './types/UserWithRelations.js'; +export type { PremiumBillingCadence } from '@metaboost/helpers'; +export type { ResolvedProductMembership } from '@metaboost/helpers'; +export { BillingPriceCatalogService } from './services/billingPriceCatalog.js'; +export { ProductMembershipSettingsService } from './services/productMembershipSettings.js'; +export { BillingProduct } from './entities/BillingProduct.js'; +export { BillingPrice } from './entities/BillingPrice.js'; +export { ProductMembershipSettings } from './entities/ProductMembershipSettings.js'; export { UserService } from './services/UserService.js'; export { TermsVersionService } from './services/TermsVersionService.js'; export { @@ -57,3 +64,20 @@ export { BucketMessageService } from './services/BucketMessageService.js'; export { BucketRSSChannelInfoService } from './services/BucketRSSChannelInfoService.js'; export { BucketRSSItemInfoService } from './services/BucketRSSItemInfoService.js'; export { BucketRoleService } from './services/BucketRoleService.js'; +export { BillingDomainEvent } from './entities/BillingDomainEvent.js'; +export { BillingDomainEventLogService } from './services/billingDomainEventLog.js'; +export { MembershipPeriodExtensionService } from './services/membershipPeriodExtension.js'; +export { + BillingRenewalOrchestratorService, + type ProcessDueRenewalsSummary, +} from './services/billingRenewalOrchestrator.js'; +export { + BillingPriceGovernanceService, + BILLING_PRICE_GOVERNANCE_DEFAULT_CURRENCY, +} from './services/billingPriceGovernance.js'; +export type { + ActiveBillingPriceRow, + BillingPriceChangeAuditRow, + BillingPriceWindowRow, + BillingPriceWindowStatus, +} from './services/billingPriceGovernance.js'; diff --git a/packages/orm/src/lib/defaultMembershipExpiresAt.ts b/packages/orm/src/lib/defaultMembershipExpiresAt.ts new file mode 100644 index 00000000..462fc819 --- /dev/null +++ b/packages/orm/src/lib/defaultMembershipExpiresAt.ts @@ -0,0 +1,23 @@ +import type { MembershipTier } from '@metaboost/helpers'; + +import { resolveInitialMembershipExpiresAt, type PremiumBillingCadence } from '@metaboost/helpers'; + +import { readFreeTrialExpirationSeconds } from './freeTrialExpirationSeconds.js'; + +export type { PremiumBillingCadence }; + +/** + * Default membership expiry when the caller does not pass an explicit `membershipExpiresAt`. + * Trial: now + MEMBERSHIP_FREE_TRIAL_EXPIRATION (seconds). + * Premium: +1 month (monthly cadence) or +12 months (annual cadence); defaults to annual when unspecified. + */ +export function resolveDefaultMembershipExpiresAt(options: { + membershipTier: MembershipTier; + premiumBillingCadence?: PremiumBillingCadence; +}): Date { + return resolveInitialMembershipExpiresAt({ + membershipTier: options.membershipTier, + premiumBillingCadence: options.premiumBillingCadence, + trialExpirationSeconds: readFreeTrialExpirationSeconds(), + }); +} diff --git a/packages/orm/src/lib/freeTrialExpirationSeconds.ts b/packages/orm/src/lib/freeTrialExpirationSeconds.ts new file mode 100644 index 00000000..1189825d --- /dev/null +++ b/packages/orm/src/lib/freeTrialExpirationSeconds.ts @@ -0,0 +1,25 @@ +import { DEFAULT_FREE_TRIAL_EXPIRATION } from '@metaboost/helpers'; + +/** + * `MEMBERSHIP_FREE_TRIAL_EXPIRATION` is an optional non-negative integer — **seconds**. + * Unset uses the same default as `@metaboost/helpers` {@link DEFAULT_FREE_TRIAL_EXPIRATION}. + */ +function parseOptionalPositiveExpirationSeconds(name: string, defaultSeconds: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === null || String(raw).trim() === '') { + return defaultSeconds; + } + const n = Number(String(raw).trim()); + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) { + throw new TypeError(`${name} must be a positive integer (seconds), received: ${String(raw)}`); + } + return n; +} + +/** Trial-tier default membership window duration from env `MEMBERSHIP_FREE_TRIAL_EXPIRATION` (seconds). */ +export function readFreeTrialExpirationSeconds(): number { + return parseOptionalPositiveExpirationSeconds( + 'MEMBERSHIP_FREE_TRIAL_EXPIRATION', + DEFAULT_FREE_TRIAL_EXPIRATION + ); +} diff --git a/packages/orm/src/lib/membershipDefaultMonths.ts b/packages/orm/src/lib/membershipDefaultMonths.ts deleted file mode 100644 index bb17636b..00000000 --- a/packages/orm/src/lib/membershipDefaultMonths.ts +++ /dev/null @@ -1,29 +0,0 @@ -const MIN_MONTHS = 1; -const MAX_MONTHS = 120; - -const DEFAULT_TRIAL_MONTHS = 3; -const DEFAULT_PREMIUM_MONTHS = 12; - -function parseMembershipMonthsEnv(name: string, fallback: number): number { - const raw = process.env[name]; - if (raw === undefined || raw === '') { - return fallback; - } - const n = Number.parseInt(raw, 10); - if (!Number.isFinite(n) || n < MIN_MONTHS || n > MAX_MONTHS) { - throw new Error( - `${name} must be an integer from ${String(MIN_MONTHS)} to ${String(MAX_MONTHS)}, received: ${String(raw)}` - ); - } - return n; -} - -/** Months added for default trial-tier expiry (`MEMBERSHIP_DEFAULT_TRIAL_MONTHS`, default 3). */ -export function membershipDefaultTrialMonths(): number { - return parseMembershipMonthsEnv('MEMBERSHIP_DEFAULT_TRIAL_MONTHS', DEFAULT_TRIAL_MONTHS); -} - -/** Months added for default premium-tier expiry (`MEMBERSHIP_DEFAULT_PREMIUM_MONTHS`, default 12). */ -export function membershipDefaultPremiumMonths(): number { - return parseMembershipMonthsEnv('MEMBERSHIP_DEFAULT_PREMIUM_MONTHS', DEFAULT_PREMIUM_MONTHS); -} diff --git a/packages/orm/src/services/UserService.ts b/packages/orm/src/services/UserService.ts index df70ae11..30097e33 100644 --- a/packages/orm/src/services/UserService.ts +++ b/packages/orm/src/services/UserService.ts @@ -1,17 +1,18 @@ import type { UserWithRelations } from '../types/UserWithRelations.js'; -import { addMonths, generateRandomIdText, MembershipTier } from '@metaboost/helpers'; +import { + generateRandomIdText, + MembershipTier, + membershipTierFromStoredValue, + type PremiumBillingCadence, +} from '@metaboost/helpers'; 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 { - membershipDefaultPremiumMonths, - membershipDefaultTrialMonths, -} from '../lib/membershipDefaultMonths.js'; - +import { resolveDefaultMembershipExpiresAt } from '../lib/defaultMembershipExpiresAt.js'; const USER_RELATIONS = ['credentials', 'bio', 'trustSettings'] as const; export class UserService { @@ -69,6 +70,8 @@ export class UserService { membershipTier?: MembershipTier; membershipExpiresAt?: Date | null; autoRenew?: boolean; + /** When tier is Premium and `membershipExpiresAt` is omitted: monthly +1 month, else +12 months. */ + premiumBillingCadence?: PremiumBillingCadence; }): Promise { const hasEmail = data.email !== undefined && data.email !== null && data.email !== ''; const hasUsername = @@ -109,15 +112,17 @@ export class UserService { await bioRepo.save(bio); const membershipTier = data.membershipTier ?? MembershipTier.Trial; - const defaultMembershipExpiresAt = - membershipTier === MembershipTier.Premium - ? addMonths(new Date(), membershipDefaultPremiumMonths()) - : addMonths(new Date(), membershipDefaultTrialMonths()); + const defaultMembershipExpiresAt = resolveDefaultMembershipExpiresAt({ + membershipTier, + premiumBillingCadence: data.premiumBillingCadence, + }); + const autoRenewVal = data.autoRenew ?? membershipTier === MembershipTier.Premium; const trustSettings = trustSettingsRepo.create({ userId: savedUser.id, membershipTier, membershipExpiresAt: data.membershipExpiresAt ?? defaultMembershipExpiresAt, - autoRenew: data.autoRenew ?? membershipTier === MembershipTier.Premium, + autoRenew: autoRenewVal, + autoRenewMode: autoRenewVal ? 'on' : 'off', }); await trustSettingsRepo.save(trustSettings); @@ -188,20 +193,29 @@ export class UserService { membershipTier?: MembershipTier; membershipExpiresAt?: Date | null; autoRenew?: boolean; + premiumBillingCadence?: PremiumBillingCadence; }): Promise { const repo = appDataSourceReadWrite.getRepository(UserTrustSettings); const existing = await repo.findOne({ where: { userId: data.userId } }); - const nextMembershipTier = - data.membershipTier ?? existing?.membershipTier ?? MembershipTier.Trial; + const prevTier = + membershipTierFromStoredValue(existing?.membershipTier) ?? MembershipTier.Trial; + const nextMembershipTier = data.membershipTier ?? prevTier; const membershipTierChanged = - existing !== null && - data.membershipTier !== undefined && - data.membershipTier !== existing.membershipTier; - const defaultExpiry = - nextMembershipTier === MembershipTier.Premium - ? addMonths(new Date(), membershipDefaultPremiumMonths()) - : addMonths(new Date(), membershipDefaultTrialMonths()); + data.membershipTier !== undefined && data.membershipTier !== prevTier; + const defaultExpiry = resolveDefaultMembershipExpiresAt({ + membershipTier: nextMembershipTier, + premiumBillingCadence: data.premiumBillingCadence, + }); + + const resolvedAutoRenew = + data.autoRenew !== undefined + ? data.autoRenew + : membershipTierChanged || existing === null + ? nextMembershipTier === MembershipTier.Premium + : existing.autoRenew; + + const autoRenewMode: 'off' | 'on' = resolvedAutoRenew ? 'on' : 'off'; const payload = { membershipTier: nextMembershipTier, @@ -211,12 +225,8 @@ export class UserService { : membershipTierChanged || existing?.membershipExpiresAt === null || existing === null ? defaultExpiry : existing.membershipExpiresAt, - autoRenew: - data.autoRenew !== undefined - ? data.autoRenew - : membershipTierChanged || existing === null - ? nextMembershipTier === MembershipTier.Premium - : existing.autoRenew, + autoRenew: resolvedAutoRenew, + autoRenewMode, }; if (existing === null) { diff --git a/packages/orm/src/services/billingDomainEventLog.ts b/packages/orm/src/services/billingDomainEventLog.ts new file mode 100644 index 00000000..3833b4c6 --- /dev/null +++ b/packages/orm/src/services/billingDomainEventLog.ts @@ -0,0 +1,46 @@ +import type { BillingDomainEventType } from '@metaboost/helpers'; +import type { EntityManager } from 'typeorm'; + +import { appDataSourceReadWrite } from '../data-source.js'; +import { BillingDomainEvent } from '../entities/BillingDomainEvent.js'; + +function isUniqueViolation(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + (error as { code: string }).code === '23505' + ); +} + +export class BillingDomainEventLogService { + async append(params: { + eventType: BillingDomainEventType; + userId: string; + idempotencyKey?: string | null; + payload?: Record; + manager?: EntityManager; + }): Promise { + const manager = params.manager ?? appDataSourceReadWrite.manager; + const repo = manager.getRepository(BillingDomainEvent); + try { + await repo.save( + repo.create({ + eventType: params.eventType, + userId: params.userId, + idempotencyKey: params.idempotencyKey ?? null, + payload: params.payload ?? {}, + }) + ); + } catch (error) { + if ( + isUniqueViolation(error) && + params.idempotencyKey !== null && + params.idempotencyKey !== undefined + ) { + return; + } + throw error; + } + } +} diff --git a/packages/orm/src/services/billingPriceCatalog.ts b/packages/orm/src/services/billingPriceCatalog.ts new file mode 100644 index 00000000..e3ffe984 --- /dev/null +++ b/packages/orm/src/services/billingPriceCatalog.ts @@ -0,0 +1,164 @@ +import type { DataSource } from 'typeorm'; + +import { + BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM, + resolveProductMembershipDefaultsFromEnv, + type ResolvedProductMembership, +} from '@metaboost/helpers'; + +import { appDataSourceRead, appDataSourceReadWrite } from '../data-source.js'; +import { ProductMembershipSettingsService } from './productMembershipSettings.js'; + +type ActivePricingRow = { + billing_cadence: 'monthly' | 'annual'; + amount_cents: number; +}; + +const PREMIUM_PRODUCT_CODE = BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM; +const USD_CURRENCY_CODE = 'USD'; + +export class BillingPriceCatalogService { + private dataSourceRead: DataSource; + private dataSourceReadWrite: DataSource; + + constructor(params?: { dataSourceRead?: DataSource; dataSourceReadWrite?: DataSource }) { + this.dataSourceRead = params?.dataSourceRead ?? appDataSourceRead; + this.dataSourceReadWrite = params?.dataSourceReadWrite ?? appDataSourceReadWrite; + } + + private async getActivePremiumPricingRows(now: Date): Promise { + const rows = await this.dataSourceRead.query( + ` + SELECT DISTINCT ON (p.billing_cadence) + p.billing_cadence, + p.amount_cents + FROM billing_price p + INNER JOIN billing_product bp + ON bp.id = p.billing_product_id + WHERE bp.product_code = $1 + AND bp.is_active = TRUE + AND p.currency_code = $2 + AND p.effective_from <= $3 + AND (p.effective_to IS NULL OR p.effective_to > $3) + ORDER BY p.billing_cadence, p.effective_from DESC + `, + [PREMIUM_PRODUCT_CODE, USD_CURRENCY_CODE, now] + ); + + return rows as ActivePricingRow[]; + } + + async ensureProductMembershipTrialSeededFromEnv(now = new Date()): Promise { + await ProductMembershipSettingsService.ensureSingletonSeededFromEnv(now); + } + + async ensurePremiumPricingSeededFromEnv(now = new Date()): Promise { + const envDefaults = resolveProductMembershipDefaultsFromEnv(); + const monthlyAmountCents = Math.round(envDefaults.premiumMembershipCostMonthly * 100); + const annualAmountCents = Math.round(envDefaults.premiumMembershipCostAnnually * 100); + + await this.dataSourceReadWrite.transaction(async (transactionalEntityManager) => { + await transactionalEntityManager.query( + ` + INSERT INTO billing_product (product_code, name, is_active) + SELECT $1, $2, TRUE + WHERE NOT EXISTS ( + SELECT 1 + FROM billing_product + WHERE product_code = $1 + ) + `, + [PREMIUM_PRODUCT_CODE, 'Premium Membership'] + ); + + const rows = (await transactionalEntityManager.query( + ` + SELECT DISTINCT ON (p.billing_cadence) + p.billing_cadence, + p.amount_cents + FROM billing_price p + INNER JOIN billing_product bp + ON bp.id = p.billing_product_id + WHERE bp.product_code = $1 + AND bp.is_active = TRUE + AND p.currency_code = $2 + AND p.effective_from <= $3 + AND (p.effective_to IS NULL OR p.effective_to > $3) + ORDER BY p.billing_cadence, p.effective_from DESC + `, + [PREMIUM_PRODUCT_CODE, USD_CURRENCY_CODE, now] + )) as ActivePricingRow[]; + + const hasMonthly = rows.some((row) => row.billing_cadence === 'monthly'); + const hasAnnual = rows.some((row) => row.billing_cadence === 'annual'); + + if (!hasMonthly) { + await transactionalEntityManager.query( + ` + INSERT INTO billing_price ( + billing_product_id, + currency_code, + billing_cadence, + amount_cents, + effective_from, + effective_to, + source + ) + SELECT id, $1, $2, $3, $4, NULL, 'env_bootstrap' + FROM billing_product + WHERE product_code = $5 + `, + [USD_CURRENCY_CODE, 'monthly', monthlyAmountCents, now, PREMIUM_PRODUCT_CODE] + ); + } + + if (!hasAnnual) { + await transactionalEntityManager.query( + ` + INSERT INTO billing_price ( + billing_product_id, + currency_code, + billing_cadence, + amount_cents, + effective_from, + effective_to, + source + ) + SELECT id, $1, $2, $3, $4, NULL, 'env_bootstrap' + FROM billing_product + WHERE product_code = $5 + `, + [USD_CURRENCY_CODE, 'annual', annualAmountCents, now, PREMIUM_PRODUCT_CODE] + ); + } + }); + } + + async resolveProductMembership(now = new Date()): Promise { + const envDefaults = resolveProductMembershipDefaultsFromEnv(); + + await this.ensurePremiumPricingSeededFromEnv(now); + await this.ensureProductMembershipTrialSeededFromEnv(now); + + const rows = await this.getActivePremiumPricingRows(now); + const settingsRow = await ProductMembershipSettingsService.findSingleton(); + const monthlyRow = rows.find((row) => row.billing_cadence === 'monthly'); + const annualRow = rows.find((row) => row.billing_cadence === 'annual'); + + return { + ...envDefaults, + freeTrialExpirationSeconds: + settingsRow !== null + ? settingsRow.freeTrialExpirationSeconds + : envDefaults.freeTrialExpirationSeconds, + premiumMembershipCostMonthly: + monthlyRow !== undefined + ? Number((monthlyRow.amount_cents / 100).toFixed(2)) + : envDefaults.premiumMembershipCostMonthly, + premiumMembershipCostAnnually: + annualRow !== undefined + ? Number((annualRow.amount_cents / 100).toFixed(2)) + : envDefaults.premiumMembershipCostAnnually, + }; + } +} diff --git a/packages/orm/src/services/billingPriceGovernance.ts b/packages/orm/src/services/billingPriceGovernance.ts new file mode 100644 index 00000000..c86e668a --- /dev/null +++ b/packages/orm/src/services/billingPriceGovernance.ts @@ -0,0 +1,342 @@ +import type { DataSource } from 'typeorm'; + +import { BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM } from '@metaboost/helpers'; + +import { appDataSourceRead, appDataSourceReadWrite } from '../data-source.js'; + +const USD = 'USD'; + +type OpenPriceRow = { + id: number; + amount_cents: number; + effective_from: Date; + effective_to: Date | null; +}; + +export type ActiveBillingPriceRow = { + id: number; + billingProductId: number; + productCode: string; + currencyCode: string; + billingCadence: 'monthly' | 'annual'; + amountCents: number; + effectiveFrom: Date; + effectiveTo: Date | null; + source: string; +}; + +export type BillingPriceWindowStatus = 'active' | 'scheduled' | 'historical'; + +export type BillingPriceWindowRow = ActiveBillingPriceRow & { + status: BillingPriceWindowStatus; +}; + +export type BillingPriceChangeAuditRow = { + id: number; + billingPriceId: number | null; + changedByManagementUserId: string | null; + changeReason: string | null; + previousAmountCents: number | null; + newAmountCents: number | null; + previousEffectiveFrom: Date | null; + previousEffectiveTo: Date | null; + newEffectiveFrom: Date | null; + newEffectiveTo: Date | null; + createdAt: Date; + currencyCode: string | null; + billingCadence: 'monthly' | 'annual' | null; + productCode: string | null; +}; + +function classifyPriceWindow( + effectiveFrom: Date, + effectiveTo: Date | null, + now: Date +): BillingPriceWindowStatus { + if (effectiveFrom.getTime() > now.getTime()) { + return 'scheduled'; + } + if (effectiveTo !== null && effectiveTo.getTime() <= now.getTime()) { + return 'historical'; + } + return 'active'; +} + +/** + * Management-only catalog mutations with `billing_price_change_audit` rows. + */ +export class BillingPriceGovernanceService { + private readonly read: DataSource; + private readonly readWrite: DataSource; + + constructor(params?: { dataSourceRead?: DataSource; dataSourceReadWrite?: DataSource }) { + this.read = params?.dataSourceRead ?? appDataSourceRead; + this.readWrite = params?.dataSourceReadWrite ?? appDataSourceReadWrite; + } + + async listActivePrices(now = new Date()): Promise { + const rows = (await this.read.query( + ` + SELECT + p.id, + p.billing_product_id AS "billingProductId", + bp.product_code AS "productCode", + p.currency_code AS "currencyCode", + p.billing_cadence AS "billingCadence", + p.amount_cents AS "amountCents", + p.effective_from AS "effectiveFrom", + p.effective_to AS "effectiveTo", + p.source + FROM billing_price p + INNER JOIN billing_product bp ON bp.id = p.billing_product_id + WHERE bp.is_active = TRUE + AND bp.product_code = $2 + AND p.effective_from <= $1 + AND (p.effective_to IS NULL OR p.effective_to > $1) + ORDER BY bp.product_code, p.currency_code, p.billing_cadence, p.effective_from DESC + `, + [now, BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM] + )) as ActiveBillingPriceRow[]; + return rows; + } + + /** + * All premium membership price windows with lifecycle status relative to `now` + * (active, scheduled future windows, closed historical rows). + */ + async listPriceWindows(now = new Date()): Promise { + const rows = (await this.read.query( + ` + SELECT + p.id, + p.billing_product_id AS "billingProductId", + bp.product_code AS "productCode", + p.currency_code AS "currencyCode", + p.billing_cadence AS "billingCadence", + p.amount_cents AS "amountCents", + p.effective_from AS "effectiveFrom", + p.effective_to AS "effectiveTo", + p.source + FROM billing_price p + INNER JOIN billing_product bp ON bp.id = p.billing_product_id + WHERE bp.product_code = $1 + ORDER BY p.currency_code, p.billing_cadence, p.effective_from DESC + `, + [BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM] + )) as ActiveBillingPriceRow[]; + + return rows.map((row) => ({ + ...row, + status: classifyPriceWindow(row.effectiveFrom, row.effectiveTo, now), + })); + } + + /** Recent price governance audit rows (newest first). */ + async listPriceChangeAudit(limit: number): Promise { + const capped = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 200) : 100; + const rows = (await this.read.query( + ` + SELECT + a.id, + a.billing_price_id AS "billingPriceId", + a.changed_by_management_user_id AS "changedByManagementUserId", + a.change_reason AS "changeReason", + a.previous_amount_cents AS "previousAmountCents", + a.new_amount_cents AS "newAmountCents", + a.previous_effective_from AS "previousEffectiveFrom", + a.previous_effective_to AS "previousEffectiveTo", + a.new_effective_from AS "newEffectiveFrom", + a.new_effective_to AS "newEffectiveTo", + a.created_at AS "createdAt", + p.currency_code AS "currencyCode", + p.billing_cadence AS "billingCadence", + bp.product_code AS "productCode" + FROM billing_price_change_audit a + LEFT JOIN billing_price p ON p.id = a.billing_price_id + LEFT JOIN billing_product bp ON bp.id = p.billing_product_id + ORDER BY a.created_at DESC + LIMIT $1 + `, + [capped] + )) as BillingPriceChangeAuditRow[]; + return rows; + } + + async schedulePriceChange(params: { + currencyCode: string; + billingCadence: 'monthly' | 'annual'; + amountCents: number; + effectiveFrom: Date; + changeReason?: string | null; + changedByManagementUserId: string | null; + source?: string; + }): Promise<{ newPriceId: number }> { + const source = params.source ?? 'manual'; + const productCode = BILLING_PRODUCT_CODE_MEMBERSHIP_PREMIUM; + + return this.readWrite.transaction(async (manager) => { + const productRows = (await manager.query( + `SELECT id FROM billing_product WHERE product_code = $1`, + [productCode] + )) as Array<{ id: number }>; + const productRow = productRows[0]; + if (productRow === undefined) { + throw new Error('BillingPriceGovernanceService.schedulePriceChange: product missing'); + } + const billingProductId = productRow.id; + + const openRows = (await manager.query( + ` + SELECT id, amount_cents, effective_from, effective_to + FROM billing_price + WHERE billing_product_id = $1 + AND currency_code = $2 + AND billing_cadence = $3 + AND effective_to IS NULL + `, + [billingProductId, params.currencyCode, params.billingCadence] + )) as OpenPriceRow[]; + + const prevOpen = openRows[0]; + const effectiveFrom = params.effectiveFrom; + + if (prevOpen !== undefined) { + await manager.query( + `UPDATE billing_price SET effective_to = $1, updated_at = NOW() WHERE id = $2`, + [effectiveFrom, prevOpen.id] + ); + } + + const inserted = (await manager.query( + ` + INSERT INTO billing_price ( + billing_product_id, + currency_code, + billing_cadence, + amount_cents, + effective_from, + effective_to, + source + ) + VALUES ($1, $2, $3, $4, $5, NULL, $6) + RETURNING id + `, + [ + billingProductId, + params.currencyCode, + params.billingCadence, + params.amountCents, + effectiveFrom, + source, + ] + )) as Array<{ id: number }>; + + const newId = inserted[0]?.id; + if (newId === undefined) { + throw new Error('BillingPriceGovernanceService.schedulePriceChange: insert failed'); + } + + await manager.query( + ` + INSERT INTO billing_price_change_audit ( + billing_price_id, + changed_by_management_user_id, + change_reason, + previous_amount_cents, + new_amount_cents, + previous_effective_from, + previous_effective_to, + new_effective_from, + new_effective_to + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + [ + newId, + params.changedByManagementUserId, + params.changeReason ?? null, + prevOpen?.amount_cents ?? null, + params.amountCents, + prevOpen?.effective_from ?? null, + prevOpen?.effective_to ?? null, + effectiveFrom, + null, + ] + ); + + return { newPriceId: newId }; + }); + } + + async deprecatePrice(params: { + priceId: number; + changeReason?: string | null; + changedByManagementUserId: string | null; + now?: Date; + }): Promise { + const now = params.now ?? new Date(); + + await this.readWrite.transaction(async (manager) => { + const rows = (await manager.query( + ` + SELECT + p.id, + p.amount_cents, + p.effective_from, + p.effective_to + FROM billing_price p + WHERE p.id = $1 + `, + [params.priceId] + )) as Array<{ + id: number; + amount_cents: number; + effective_from: Date; + effective_to: Date | null; + }>; + + const row = rows[0]; + if (row === undefined) { + throw new Error('BillingPriceGovernanceService.deprecatePrice: price not found'); + } + if (row.effective_to !== null && row.effective_to <= now) { + return; + } + + await manager.query( + `UPDATE billing_price SET effective_to = $1, updated_at = NOW() WHERE id = $2`, + [now, params.priceId] + ); + + await manager.query( + ` + INSERT INTO billing_price_change_audit ( + billing_price_id, + changed_by_management_user_id, + change_reason, + previous_amount_cents, + new_amount_cents, + previous_effective_from, + previous_effective_to, + new_effective_from, + new_effective_to + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + [ + params.priceId, + params.changedByManagementUserId, + params.changeReason ?? null, + row.amount_cents, + null, + row.effective_from, + row.effective_to, + row.effective_from, + now, + ] + ); + }); + } +} + +export const BILLING_PRICE_GOVERNANCE_DEFAULT_CURRENCY = USD; diff --git a/packages/orm/src/services/billingRenewalOrchestrator.ts b/packages/orm/src/services/billingRenewalOrchestrator.ts new file mode 100644 index 00000000..1b36e67a --- /dev/null +++ b/packages/orm/src/services/billingRenewalOrchestrator.ts @@ -0,0 +1,216 @@ +import type { BillingCadence } from '@metaboost/helpers'; +import type { EntityManager } from 'typeorm'; + +import { + BILLING_DOMAIN_EVENT_TYPES, + MembershipTier, + createStubBillingRenewalProviderAdapter, + type BillingRenewalProviderAdapter, +} from '@metaboost/helpers'; + +import { appDataSourceReadWrite } from '../data-source.js'; +import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { BillingDomainEventLogService } from './billingDomainEventLog.js'; +import { MembershipPeriodExtensionService } from './membershipPeriodExtension.js'; + +const RENEWAL_BACKOFF_SECONDS = [3600, 14_400, 43_200, 86_400] as const; + +function parseCadence(raw: string | null): BillingCadence | null { + if (raw === 'monthly' || raw === 'annual') { + return raw; + } + return null; +} + +function renewalIdempotencyKey(userId: string, membershipExpiresAt: Date): string { + return `renewal:${userId}:${membershipExpiresAt.toISOString()}`; +} + +export type ProcessDueRenewalsSummary = { + scanned: number; + attempted: number; + succeeded: number; + failed: number; + skipped: number; +}; + +/** + * Scans premium accounts nearing expiry and attempts renewal via {@link BillingRenewalProviderAdapter}. + */ +export class BillingRenewalOrchestratorService { + private readonly adapter: BillingRenewalProviderAdapter; + + constructor(adapter?: BillingRenewalProviderAdapter) { + this.adapter = adapter ?? createStubBillingRenewalProviderAdapter(); + } + + async processDueRenewals(params: { + now?: Date; + lookaheadHours?: number; + }): Promise { + const now = params.now ?? new Date(); + const lookaheadHours = params.lookaheadHours ?? 24; + const lookaheadMs = lookaheadHours * 60 * 60 * 1000; + const horizon = new Date(now.getTime() + lookaheadMs); + + const rows = (await appDataSourceReadWrite.query( + ` + SELECT + user_id AS "userId", + membership_expires_at AS "membershipExpiresAt", + billing_cadence AS "billingCadence", + last_renewal_idempotency_key AS "lastRenewalIdempotencyKey", + last_renewal_status AS "lastRenewalStatus", + renewal_retry_count AS "renewalRetryCount" + FROM user_trust_settings + WHERE membership_tier = $1 + AND auto_renew_mode = $2 + AND billing_cadence IS NOT NULL + AND membership_expires_at IS NOT NULL + AND membership_expires_at > $3 + AND membership_expires_at <= $4 + AND (renewal_retry_backoff_until IS NULL OR renewal_retry_backoff_until <= $3) + AND (next_renewal_attempt_at IS NULL OR next_renewal_attempt_at <= $3) + `, + [MembershipTier.Premium, 'on', now, horizon] + )) as Array<{ + userId: string; + membershipExpiresAt: Date; + billingCadence: string; + lastRenewalIdempotencyKey: string | null; + lastRenewalStatus: string; + renewalRetryCount: number; + }>; + + const summary: ProcessDueRenewalsSummary = { + scanned: rows.length, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + }; + + const eventLog = new BillingDomainEventLogService(); + + for (const row of rows) { + const cadence = parseCadence(row.billingCadence); + if (cadence === null) { + summary.skipped += 1; + continue; + } + + const key = renewalIdempotencyKey(row.userId, row.membershipExpiresAt); + if (row.lastRenewalIdempotencyKey === key && row.lastRenewalStatus === 'succeeded') { + summary.skipped += 1; + continue; + } + + summary.attempted += 1; + + let attemptResult; + try { + attemptResult = await this.adapter.attemptRenewal({ + userId: row.userId, + cadence, + idempotencyKey: key, + }); + } catch { + await appDataSourceReadWrite.transaction(async (manager) => { + await this.applyRenewalFailure({ + manager, + userId: row.userId, + now, + idempotencyKey: key, + retryCount: row.renewalRetryCount, + retryAfterSeconds: 3600, + }); + await eventLog.append({ + eventType: BILLING_DOMAIN_EVENT_TYPES.RENEWAL_FAILED, + userId: row.userId, + idempotencyKey: `${key}:adapter_error`, + payload: { stage: 'adapter_throw' }, + manager, + }); + }); + summary.failed += 1; + continue; + } + + if (attemptResult.status === 'succeeded') { + await appDataSourceReadWrite.transaction(async (manager) => { + await MembershipPeriodExtensionService.extendPremiumByCadence({ + userId: row.userId, + cadence, + idempotencyKey: key, + reason: 'renewal_success', + now, + manager, + }); + await manager.getRepository(UserTrustSettings).update( + { userId: row.userId }, + { + lastRenewalAttemptAt: now, + lastRenewalStatus: 'succeeded', + lastRenewalIdempotencyKey: key, + renewalRetryCount: 0, + nextRenewalAttemptAt: null, + renewalRetryBackoffUntil: null, + } + ); + }); + summary.succeeded += 1; + } else { + const fallbackBackoff = + RENEWAL_BACKOFF_SECONDS[ + Math.min(row.renewalRetryCount, RENEWAL_BACKOFF_SECONDS.length - 1) + ] ?? 3600; + const retryAfterSeconds = attemptResult.retryAfterSeconds ?? fallbackBackoff; + await appDataSourceReadWrite.transaction(async (manager) => { + await this.applyRenewalFailure({ + manager, + userId: row.userId, + now, + idempotencyKey: key, + retryCount: row.renewalRetryCount, + retryAfterSeconds, + }); + await eventLog.append({ + eventType: BILLING_DOMAIN_EVENT_TYPES.RENEWAL_FAILED, + userId: row.userId, + idempotencyKey: `${key}:failed`, + payload: { + providerAttemptId: attemptResult.providerAttemptId ?? null, + }, + manager, + }); + }); + summary.failed += 1; + } + } + + return summary; + } + + private async applyRenewalFailure(params: { + manager: EntityManager; + userId: string; + now: Date; + idempotencyKey: string; + retryCount: number; + retryAfterSeconds: number; + }): Promise { + const nextAttempt = new Date(params.now.getTime() + params.retryAfterSeconds * 1000); + const repo = params.manager.getRepository(UserTrustSettings); + await repo.update( + { userId: params.userId }, + { + lastRenewalAttemptAt: params.now, + lastRenewalStatus: 'failed', + lastRenewalIdempotencyKey: params.idempotencyKey, + renewalRetryCount: params.retryCount + 1, + nextRenewalAttemptAt: nextAttempt, + renewalRetryBackoffUntil: nextAttempt, + } + ); + } +} diff --git a/packages/orm/src/services/membershipPeriodExtension.ts b/packages/orm/src/services/membershipPeriodExtension.ts new file mode 100644 index 00000000..69b9281c --- /dev/null +++ b/packages/orm/src/services/membershipPeriodExtension.ts @@ -0,0 +1,107 @@ +import type { EntityManager } from 'typeorm'; + +import { + BILLING_DOMAIN_EVENT_TYPES, + MembershipTier, + extendMembershipPeriodByCadence, + type BillingCadence, + type MembershipPeriodExtensionReason, +} from '@metaboost/helpers'; + +import { appDataSourceReadWrite } from '../data-source.js'; +import { UserTrustSettings } from '../entities/UserTrustSettings.js'; +import { BillingDomainEventLogService } from './billingDomainEventLog.js'; + +function eventTypeForExtensionReason( + reason: MembershipPeriodExtensionReason +): (typeof BILLING_DOMAIN_EVENT_TYPES)[keyof typeof BILLING_DOMAIN_EVENT_TYPES] | null { + if (reason === 'renewal_success') { + return BILLING_DOMAIN_EVENT_TYPES.RENEWAL_SUCCEEDED; + } + if (reason === 'payment_settled' || reason === 'pay_on_demand_extension') { + return BILLING_DOMAIN_EVENT_TYPES.PAYMENT_SETTLED; + } + return null; +} + +export class MembershipPeriodExtensionService { + /** + * Extends premium membership expiry using {@link extendMembershipPeriodByCadence} and records + * idempotency on trust settings. Delegates all calendar math to helpers. + */ + static async extendPremiumByCadence(params: { + userId: string; + cadence: BillingCadence; + idempotencyKey: string; + reason: MembershipPeriodExtensionReason; + now?: Date; + manager?: EntityManager; + }): Promise<{ applied: boolean; membershipExpiresAt: Date | null }> { + const now = params.now ?? new Date(); + const run = async ( + manager: EntityManager + ): Promise<{ + applied: boolean; + membershipExpiresAt: Date | null; + }> => { + const repo = manager.getRepository(UserTrustSettings); + const trust = await repo.findOne({ where: { userId: params.userId } }); + if (trust === null) { + throw new Error( + 'MembershipPeriodExtensionService.extendPremiumByCadence: trust settings missing' + ); + } + if (trust.membershipTier !== MembershipTier.Premium) { + throw new Error( + 'MembershipPeriodExtensionService.extendPremiumByCadence: user is not premium' + ); + } + if (trust.lastExtensionIdempotencyKey === params.idempotencyKey) { + return { applied: false, membershipExpiresAt: trust.membershipExpiresAt }; + } + const newExpiry = extendMembershipPeriodByCadence({ + membershipExpiresAt: trust.membershipExpiresAt, + cadence: params.cadence, + now, + }); + trust.membershipExpiresAt = newExpiry; + trust.billingCadence = params.cadence; + trust.lastExtensionIdempotencyKey = params.idempotencyKey; + await repo.save(trust); + + const eventType = eventTypeForExtensionReason(params.reason); + if (eventType !== null) { + const log = new BillingDomainEventLogService(); + await log.append({ + eventType, + userId: params.userId, + idempotencyKey: params.idempotencyKey, + payload: { reason: params.reason }, + manager, + }); + } + + return { applied: true, membershipExpiresAt: newExpiry }; + }; + + if (params.manager !== undefined) { + return run(params.manager); + } + return appDataSourceReadWrite.transaction(run); + } + + static async logPayOnDemandExtensionRequested(params: { + userId: string; + idempotencyKey: string; + manager?: EntityManager; + }): Promise { + const log = new BillingDomainEventLogService(); + await log.append({ + eventType: BILLING_DOMAIN_EVENT_TYPES.PAY_ON_DEMAND_EXTENSION_REQUESTED, + userId: params.userId, + idempotencyKey: params.idempotencyKey, + payload: {}, + manager: params.manager, + }); + } +} diff --git a/packages/orm/src/services/productMembershipSettings.ts b/packages/orm/src/services/productMembershipSettings.ts new file mode 100644 index 00000000..f75170a0 --- /dev/null +++ b/packages/orm/src/services/productMembershipSettings.ts @@ -0,0 +1,36 @@ +import { resolveProductMembershipDefaultsFromEnv } from '@metaboost/helpers'; + +import { appDataSourceRead, appDataSourceReadWrite } from '../data-source.js'; +import { ProductMembershipSettings } from '../entities/ProductMembershipSettings.js'; + +/** + * Canonical one-row settings (`id = 1`) for premium membership product defaults. + * Env is used only to bootstrap an empty row; existing DB values are never overwritten. + */ +export class ProductMembershipSettingsService { + static async findSingleton(): Promise { + return appDataSourceRead.getRepository(ProductMembershipSettings).findOne({ + where: { id: 1 }, + }); + } + + /** + * Insert row 1 from env when missing (`ON CONFLICT DO NOTHING`). Does not update existing rows. + */ + static async ensureSingletonSeededFromEnv(now = new Date()): Promise { + const envDefaults = resolveProductMembershipDefaultsFromEnv(); + await appDataSourceReadWrite.query( + ` + INSERT INTO product_membership_settings ( + id, + free_trial_expiration_seconds, + created_at, + updated_at + ) + VALUES (1, $1, $2, $2) + ON CONFLICT (id) DO NOTHING + `, + [envDefaults.freeTrialExpirationSeconds, now] + ); + } +} diff --git a/packages/ui/src/components/form/FormActions/FormActions.module.scss b/packages/ui/src/components/form/FormActions/FormActions.module.scss index 85f8fe66..f0b87d5a 100644 --- a/packages/ui/src/components/form/FormActions/FormActions.module.scss +++ b/packages/ui/src/components/form/FormActions/FormActions.module.scss @@ -2,5 +2,6 @@ .actions { display: flex; + justify-content: flex-end; gap: $gap-button-row; } diff --git a/scripts/api/process-due-renewals.mjs b/scripts/api/process-due-renewals.mjs new file mode 100644 index 00000000..470165ab --- /dev/null +++ b/scripts/api/process-due-renewals.mjs @@ -0,0 +1,31 @@ +/** + * Runs near-expiry renewal orchestration against the configured app database. + * Loads env from apps/api/.env and infra/config/local/db.env (override). + * + * Usage (from repo root, after `npm run build:packages` and `npm run build -w @metaboost/orm`): + * node scripts/api/process-due-renewals.mjs + */ +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..'); + +dotenv.config({ path: path.join(repoRoot, 'apps', 'api', '.env') }); +dotenv.config({ path: path.join(repoRoot, 'infra', 'config', 'local', 'db.env'), override: true }); + +const { appDataSourceReadWrite, BillingRenewalOrchestratorService } = + await import('@metaboost/orm'); + +await appDataSourceReadWrite.initialize(); +try { + const orchestrator = new BillingRenewalOrchestratorService(); + const summary = await orchestrator.processDueRenewals({ + now: new Date(), + lookaheadHours: 24, + }); + console.log(JSON.stringify(summary)); +} finally { + await appDataSourceReadWrite.destroy(); +} diff --git a/scripts/k8s/kustomize-build-metaboost-bases.sh b/scripts/k8s/kustomize-build-metaboost-bases.sh index 3b9bfdbd..06a70f06 100755 --- a/scripts/k8s/kustomize-build-metaboost-bases.sh +++ b/scripts/k8s/kustomize-build-metaboost-bases.sh @@ -6,7 +6,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT" FAIL=0 -for d in api web management-api management-web db keyvaldb ops; do +for d in api web management-api management-web product-membership db keyvaldb ops; do if kubectl kustomize "infra/k8s/base/$d" --load-restrictor LoadRestrictionsNone >/dev/null; then echo "ok infra/k8s/base/$d" else diff --git a/tools/generate-data/src/management/seed.ts b/tools/generate-data/src/management/seed.ts index da5d53f5..b4db43bc 100644 --- a/tools/generate-data/src/management/seed.ts +++ b/tools/generate-data/src/management/seed.ts @@ -113,6 +113,7 @@ function personaPermissions(persona: AdminPersona): CrudMatrix { bucketsCrud: 15, bucketMessagesCrud: 15, bucketAdminsCrud: 15, + billingPricesCrud: 15, }; } if (persona === 'read-only') { @@ -122,6 +123,7 @@ function personaPermissions(persona: AdminPersona): CrudMatrix { bucketsCrud: 2, bucketMessagesCrud: 2, bucketAdminsCrud: 2, + billingPricesCrud: 2, }; } if (persona === 'bucket-focused') { @@ -131,6 +133,7 @@ function personaPermissions(persona: AdminPersona): CrudMatrix { bucketsCrud: 15, bucketMessagesCrud: 15, bucketAdminsCrud: 7, + billingPricesCrud: 0, }; } if (persona === 'bucket-admin-management-only') { @@ -140,6 +143,7 @@ function personaPermissions(persona: AdminPersona): CrudMatrix { bucketsCrud: 2, bucketMessagesCrud: 2, bucketAdminsCrud: 15, + billingPricesCrud: 0, }; } return { @@ -148,6 +152,7 @@ function personaPermissions(persona: AdminPersona): CrudMatrix { bucketsCrud: 2, bucketMessagesCrud: 2, bucketAdminsCrud: 0, + billingPricesCrud: 0, }; } @@ -306,6 +311,7 @@ export async function seedManagement(options: SeedRuntimeOptions): Promise assertCrudMask('bucketsCrud', permissions.bucketsCrud); assertCrudMask('bucketMessagesCrud', permissions.bucketMessagesCrud); assertCrudMask('bucketAdminsCrud', permissions.bucketAdminsCrud); + assertCrudMask('billingPricesCrud', permissions.billingPricesCrud); await managementDataSource.transaction(async (manager) => { await manager.query('SET CONSTRAINTS ALL DEFERRED'); @@ -341,6 +347,7 @@ export async function seedManagement(options: SeedRuntimeOptions): Promise bucketsCrud: permissions.bucketsCrud, bucketMessagesCrud: permissions.bucketMessagesCrud, bucketAdminsCrud: permissions.bucketAdminsCrud, + billingPricesCrud: permissions.billingPricesCrud, eventVisibility: persona === 'event-limited' ? 'own' : randomEventVisibility(), }) ); @@ -358,6 +365,7 @@ export async function seedManagement(options: SeedRuntimeOptions): Promise bucketsCrud: faker.helpers.arrayElement([2, 3, 6, 7, 15]), bucketMessagesCrud: faker.helpers.arrayElement([2, 3, 6, 7, 15]), bucketAdminsCrud: faker.helpers.arrayElement([2, 3, 6, 7, 15]), + billingPricesCrud: faker.helpers.arrayElement([2, 3, 6, 7, 15]), eventVisibility: randomEventVisibility(), }) ); diff --git a/tools/generate-data/src/types.ts b/tools/generate-data/src/types.ts index 30dcc09f..684429a8 100644 --- a/tools/generate-data/src/types.ts +++ b/tools/generate-data/src/types.ts @@ -18,6 +18,7 @@ export type CrudMatrix = { bucketsCrud: number; bucketMessagesCrud: number; bucketAdminsCrud: number; + billingPricesCrud: number; }; export type MainProfileCardinality = { From 658ea07138b7aca8ac34329711ff09ae2475c6ab Mon Sep 17 00:00:00 2001 From: Mitch Downey Date: Wed, 6 May 2026 01:52:07 -0500 Subject: [PATCH 02/13] feat: align local Postgres setup with Podverse standards - Introduced a new documentation file for the `local-postgres-wait-db-env` process, detailing the alignment of `local_postgres_wait` and `local_db_init_management` with `infra/config/local/db.env`. - Updated Makefiles to source environment variables from `db.env`, ensuring consistency in user roles and database management. - Removed redundant Make defaults to prevent conflicts in database identity definitions. - Enhanced the `INFRA-DOCKER-LOCAL.md` documentation to clarify the dependency on `db.env` for local infrastructure setup. --- .../local-postgres-wait-db-env-part-01.md | 26 ++++++++++++++ infra/docker/local/INFRA-DOCKER-LOCAL.md | 2 ++ makefiles/local/Makefile.local.docker.mk | 10 +++--- makefiles/local/Makefile.local.env.mk | 34 ++++++++----------- 4 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 .llm/history/active/local-postgres-wait-db-env/local-postgres-wait-db-env-part-01.md diff --git a/.llm/history/active/local-postgres-wait-db-env/local-postgres-wait-db-env-part-01.md b/.llm/history/active/local-postgres-wait-db-env/local-postgres-wait-db-env-part-01.md new file mode 100644 index 00000000..8080e71a --- /dev/null +++ b/.llm/history/active/local-postgres-wait-db-env/local-postgres-wait-db-env-part-01.md @@ -0,0 +1,26 @@ +# local-postgres-wait-db-env + +**Started:** 2026-05-06 +**Author:** Cursor Agent +**Context:** Align `local_postgres_wait` with Podverse — source `infra/config/local/db.env`, use `DB_APP_OWNER_USER` and read-role names from env (no drifting Make defaults). + +--- + +### Session 1 - 2026-05-06 + +#### Prompt (Developer) + +implement the plan + +#### Key Decisions + +- `local_postgres_wait` and `local_db_init_management` source `infra/config/local/db.env` (Podverse pattern); `psql` uses `DB_APP_OWNER_USER`; role-count query uses `DB_APP_READ_USER` / `DB_APP_READ_WRITE_USER`. +- `local_infra_up` depends on `infra/config/local/db.env` so Compose and wait share the same file. +- Removed `LOCAL_PG_USER`, `LOCAL_POSTGRES_READ_*`, `LOCAL_POSTGRES_MANAGEMENT_*`, and `LOCAL_MANAGEMENT_DB_NAME` Make defaults to avoid parallel definitions of DB identity. + +#### Files Created/Modified + +- `makefiles/local/Makefile.local.docker.mk` +- `makefiles/local/Makefile.local.env.mk` +- `infra/docker/local/INFRA-DOCKER-LOCAL.md` +- `.llm/history/active/local-postgres-wait-db-env/local-postgres-wait-db-env-part-01.md` diff --git a/infra/docker/local/INFRA-DOCKER-LOCAL.md b/infra/docker/local/INFRA-DOCKER-LOCAL.md index cab6b337..3d4522ee 100644 --- a/infra/docker/local/INFRA-DOCKER-LOCAL.md +++ b/infra/docker/local/INFRA-DOCKER-LOCAL.md @@ -11,6 +11,8 @@ localhost when run via `npm run dev` (e.g. `npm run dev:web-sidecar`, port 4001; ## First run +`make local_infra_up` requires **`infra/config/local/db.env`** (same values Docker Compose uses for `POSTGRES_USER` and bootstrap scripts). Generate it with **`make local_env_setup`** before bringing infra up. + 1. Prepare env (from repo root): `make local_env_setup` (or use the home-directory flow: `make local_env_prepare`, edit `~/.config/metaboost/local-env-overrides/`, `make local_env_link`, `make local_env_setup` — see [docs/development/env/LOCAL-ENV-OVERRIDES.md](../../docs/development/env/LOCAL-ENV-OVERRIDES.md)). diff --git a/makefiles/local/Makefile.local.docker.mk b/makefiles/local/Makefile.local.docker.mk index a0866877..078a8a2d 100644 --- a/makefiles/local/Makefile.local.docker.mk +++ b/makefiles/local/Makefile.local.docker.mk @@ -25,10 +25,12 @@ local_network_create: # Wait for Postgres to accept connections and app DB roles (read + read_write from canonical # source/bootstrap/0001_create_app_db_users.sh) so management DB init can run. -local_postgres_wait: +# Sources infra/config/local/db.env (Podverse parity): DB_APP_OWNER_USER for psql; DB_APP_READ_* for role names. +local_postgres_wait: infra/config/local/db.env @echo "Waiting for Postgres (and app read/read_write users) to be ready..." - @for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do \ - c=$$(docker exec $(LOCAL_PG_CONTAINER) psql -U $(LOCAL_PG_USER) -d postgres -tAc "SELECT COUNT(*) FROM pg_roles WHERE rolname IN ('$(LOCAL_POSTGRES_READ_USER)','$(LOCAL_POSTGRES_READ_WRITE_USER)')" 2>/dev/null || echo 0); \ + @set -a; . infra/config/local/db.env; set +a; \ + for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do \ + c=$$(docker exec $(LOCAL_PG_CONTAINER) psql -U "$$DB_APP_OWNER_USER" -d postgres -tAc "SELECT COUNT(*) FROM pg_roles WHERE rolname IN ('$$DB_APP_READ_USER','$$DB_APP_READ_WRITE_USER')" 2>/dev/null || echo 0); \ [ "$$c" = "2" ] && exit 0; \ sleep 1; \ done; \ @@ -36,7 +38,7 @@ local_postgres_wait: # Postgres + Valkey + pgAdmin only. # pgAdmin is available at http://localhost:4050 — no login required; both databases pre-connected. -local_infra_up: local_network_create +local_infra_up: local_network_create infra/config/local/db.env docker compose $(COMPOSE_LOCAL_ENV) -f $(COMPOSE_LOCAL) --project-directory . up -d postgres valkey metaboost_local_pgadmin $(MAKE) local_postgres_wait @echo "Next steps:" diff --git a/makefiles/local/Makefile.local.env.mk b/makefiles/local/Makefile.local.env.mk index 77bccd3b..b8221b61 100644 --- a/makefiles/local/Makefile.local.env.mk +++ b/makefiles/local/Makefile.local.env.mk @@ -4,16 +4,9 @@ .PHONY: env_setup local_env_remove local_db_init local_db_init_management local_reset_env_infra local_nuke_rebuild_run .PHONY: local_clean_env_setup_infra_up -# Local Postgres container (from docker-compose) and management DB name for dev +# Local Postgres container (from docker-compose). Superuser for docker exec psql: DB_APP_OWNER_USER in +# infra/config/local/db.env (same file Compose uses for POSTGRES_USER — Podverse parity; do not duplicate here). LOCAL_PG_CONTAINER ?= metaboost_local_postgres -LOCAL_PG_USER ?= metaboost_app_admin -# Must match DB_APP_READ_USER / DB_APP_READ_WRITE_USER in infra/config/local/db.env (roles from source/bootstrap/0001_create_app_db_users.sh). -LOCAL_POSTGRES_READ_USER ?= metaboost_app_read -LOCAL_POSTGRES_READ_WRITE_USER ?= metaboost_app_read_write -# Must match DB_MANAGEMENT_*_USER in infra/config/local/db.env (see scripts/local-env/local-management-db.sh). -LOCAL_POSTGRES_MANAGEMENT_READ_USER ?= metaboost_management_read -LOCAL_POSTGRES_MANAGEMENT_READ_WRITE_USER ?= metaboost_management_read_write -LOCAL_MANAGEMENT_DB_NAME ?= metaboost_management local_env_prepare: bash scripts/local-env/prepare-local-env-overrides.sh @@ -107,15 +100,16 @@ local_nuke_rebuild_run: # Reset management database in local Postgres (metaboost_local_postgres). Run after local_infra_up. # This prepares an empty management database and roles; run linear migrations and create/update the superuser as separate steps. -local_db_init_management: +local_db_init_management: infra/config/local/db.env @docker ps -q -f name=^/$(LOCAL_PG_CONTAINER)$$ | grep -q . || (echo "Error: Start local Postgres first: make local_infra_up"; exit 1) - @echo "Creating management database $(LOCAL_MANAGEMENT_DB_NAME)..." - @docker exec $(LOCAL_PG_CONTAINER) psql -U $(LOCAL_PG_USER) -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$(LOCAL_MANAGEMENT_DB_NAME)' AND pid <> pg_backend_pid();" 2>/dev/null || true - @docker exec $(LOCAL_PG_CONTAINER) psql -U $(LOCAL_PG_USER) -d postgres -c "DROP DATABASE IF EXISTS $(LOCAL_MANAGEMENT_DB_NAME);" - @docker exec $(LOCAL_PG_CONTAINER) psql -U $(LOCAL_PG_USER) -d postgres -c "CREATE DATABASE $(LOCAL_MANAGEMENT_DB_NAME);" - @bash $(ROOT)scripts/local-env/local-management-db.sh $(LOCAL_PG_CONTAINER) create-roles - @bash $(ROOT)scripts/local-env/local-management-db.sh $(LOCAL_PG_CONTAINER) grants $(LOCAL_MANAGEMENT_DB_NAME) - @echo "Management database $(LOCAL_MANAGEMENT_DB_NAME) reset with roles/grants." - @echo "Next steps:" - @echo " make local_db_migrate_linear_management" - @echo " make local_management_superuser_create # temporary Docker runner on $(LOCAL_NETWORK)" + @set -a; . infra/config/local/db.env; set +a; \ + echo "Creating management database $$DB_MANAGEMENT_NAME..."; \ + docker exec $(LOCAL_PG_CONTAINER) psql -U "$$DB_APP_OWNER_USER" -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '"'"'"$$DB_MANAGEMENT_NAME"'"'"' AND pid <> pg_backend_pid();" 2>/dev/null || true; \ + docker exec $(LOCAL_PG_CONTAINER) psql -U "$$DB_APP_OWNER_USER" -d postgres -c "DROP DATABASE IF EXISTS $$DB_MANAGEMENT_NAME;"; \ + docker exec $(LOCAL_PG_CONTAINER) psql -U "$$DB_APP_OWNER_USER" -d postgres -c "CREATE DATABASE $$DB_MANAGEMENT_NAME;"; \ + bash $(ROOT)scripts/local-env/local-management-db.sh $(LOCAL_PG_CONTAINER) create-roles; \ + bash $(ROOT)scripts/local-env/local-management-db.sh $(LOCAL_PG_CONTAINER) grants "$$DB_MANAGEMENT_NAME"; \ + echo "Management database $$DB_MANAGEMENT_NAME reset with roles/grants."; \ + echo "Next steps:"; \ + echo " make local_db_migrate_linear_management"; \ + echo " make local_management_superuser_create # temporary Docker runner on $(LOCAL_NETWORK)" From d844466763c1fbf47edef84596f8d1b899974a89 Mon Sep 17 00:00:00 2001 From: Mitch Downey Date: Wed, 6 May 2026 02:04:03 -0500 Subject: [PATCH 03/13] feat: implement dashboard breadcrumbs across management-web - Introduced a new `withDashboardBreadcrumb` utility to prepend a Dashboard link to breadcrumb navigation across various pages. - Updated multiple components and pages, including Admin, Bucket, User, and Terms Version pages, to utilize the new breadcrumb structure. - Enhanced user navigation experience by ensuring all relevant pages display a consistent breadcrumb trail leading back to the Dashboard. - Refactored breadcrumb-related imports and components for improved maintainability and clarity. --- .../management-edit-breadcrumbs/SKILL.md | 2 +- ...oard-breadcrumbs-management-web-part-01.md | 26 +++++ .../src/app/(main)/admin/[id]/edit/page.tsx | 26 ++--- .../src/app/(main)/admin/[id]/page.tsx | 100 +++++++++++------- .../src/app/(main)/admins/new/page.tsx | 26 ++--- .../src/app/(main)/admins/page.tsx | 8 ++ .../src/app/(main)/admins/roles/new/page.tsx | 29 +++-- .../src/app/(main)/bucket/[id]/new/page.tsx | 26 ++--- .../src/app/(main)/bucket/[id]/page.tsx | 19 ++-- .../settings/BucketSettingsLayoutClient.tsx | 4 + .../(main)/bucket/[id]/settings/layout.tsx | 6 +- .../src/app/(main)/buckets/new/page.tsx | 26 ++--- .../src/app/(main)/buckets/page.tsx | 8 ++ .../src/app/(main)/events/page.tsx | 74 +++++++------ .../app/(main)/global-blocked-apps/page.tsx | 16 ++- .../app/(main)/products/membership/page.tsx | 27 ++--- .../(main)/terms-version/[id]/edit/page.tsx | 26 ++--- .../app/(main)/terms-versions/new/page.tsx | 26 ++--- .../src/app/(main)/terms-versions/page.tsx | 9 +- .../src/app/(main)/user/[id]/edit/page.tsx | 26 ++--- .../src/app/(main)/user/[id]/page.tsx | 84 +++++++++------ .../src/app/(main)/users/new/page.tsx | 26 ++--- .../src/app/(main)/users/page.tsx | 8 ++ .../components/ManagementBreadcrumbLink.tsx | 19 ++++ .../src/lib/management-breadcrumbs.ts | 11 ++ .../BucketMessagesBreadcrumbs.tsx | 4 + .../BucketMessagesPageContent.tsx | 4 + .../BucketSettingsBreadcrumbs.tsx | 4 + .../FilterTablePageLayout.tsx | 21 ++-- 29 files changed, 381 insertions(+), 310 deletions(-) create mode 100644 .llm/history/active/dashboard-breadcrumbs-management-web/dashboard-breadcrumbs-management-web-part-01.md create mode 100644 apps/management-web/src/components/ManagementBreadcrumbLink.tsx create mode 100644 apps/management-web/src/lib/management-breadcrumbs.ts diff --git a/.cursor/skills/management-edit-breadcrumbs/SKILL.md b/.cursor/skills/management-edit-breadcrumbs/SKILL.md index fe692d50..cb763b57 100644 --- a/.cursor/skills/management-edit-breadcrumbs/SKILL.md +++ b/.cursor/skills/management-edit-breadcrumbs/SKILL.md @@ -12,7 +12,7 @@ description: New and Edit pages in management-web should use breadcrumbs so user Every New and Edit page should show **breadcrumbs** so users can see their position in the hierarchy and navigate back: - Wrap the page content in `ContentPageLayout` from `@metaboost/ui` with a `breadcrumbs` prop. -- Build `BreadcrumbItem[]`: list route (link) → optional view/resource link → current page label (no `href` for last item). +- Build `BreadcrumbItem[]` with **`withDashboardBreadcrumb`** from `apps/management-web/src/lib/management-breadcrumbs.ts` so the first segment is always **Dashboard** (`ROUTES.DASHBOARD`), then list route (link) → optional view/resource link → current page label (no `href` for last item). - Use `Breadcrumbs` and `Link` from `@metaboost/ui`; pass a `LinkComponent` that wraps `Link` (e.g. a small `BreadcrumbLink` that forwards `href`, `children`, `className`). - Set `contentMaxWidth="form"` on `ContentPageLayout` for form pages. diff --git a/.llm/history/active/dashboard-breadcrumbs-management-web/dashboard-breadcrumbs-management-web-part-01.md b/.llm/history/active/dashboard-breadcrumbs-management-web/dashboard-breadcrumbs-management-web-part-01.md new file mode 100644 index 00000000..acee1efc --- /dev/null +++ b/.llm/history/active/dashboard-breadcrumbs-management-web/dashboard-breadcrumbs-management-web-part-01.md @@ -0,0 +1,26 @@ +# dashboard-breadcrumbs-management-web + +**Started:** 2026-05-06 +**Author:** LLM session +**Context:** Dashboard-first breadcrumbs across management-web. + +### Session 1 - 2026-05-06 + +#### Prompt (Developer) + +in metaboost management, every page accessible via the dashboard should have a breadcrumb that brings you back to the Dashboard + +#### Key Decisions + +- Added `withDashboardBreadcrumb` + `ManagementBreadcrumbLink`; extended `FilterTablePageLayout` and bucket breadcrumb components with optional leading segments. +- List pages (admins, users, buckets, terms versions), events, global blocked apps, and existing form/detail pages now show Dashboard as the first crumb; bucket detail/settings include Dashboard via helper or `leadingItems`. + +#### Files Created/Modified + +- [apps/management-web/src/lib/management-breadcrumbs.ts](apps/management-web/src/lib/management-breadcrumbs.ts) +- [apps/management-web/src/components/ManagementBreadcrumbLink.tsx](apps/management-web/src/components/ManagementBreadcrumbLink.tsx) +- [packages/ui/src/components/layout/FilterTablePageLayout/FilterTablePageLayout.tsx](packages/ui/src/components/layout/FilterTablePageLayout/FilterTablePageLayout.tsx) +- [packages/ui/src/components/bucket/BucketSettingsBreadcrumbs/BucketSettingsBreadcrumbs.tsx](packages/ui/src/components/bucket/BucketSettingsBreadcrumbs/BucketSettingsBreadcrumbs.tsx) +- [packages/ui/src/components/bucket/BucketMessagesBreadcrumbs/BucketMessagesBreadcrumbs.tsx](packages/ui/src/components/bucket/BucketMessagesBreadcrumbs/BucketMessagesBreadcrumbs.tsx) +- [packages/ui/src/components/bucket/BucketMessagesPageContent/BucketMessagesPageContent.tsx](packages/ui/src/components/bucket/BucketMessagesPageContent/BucketMessagesPageContent.tsx) +- Multiple `apps/management-web/src/app/(main)/**` pages and [bucket settings layout/client](apps/management-web/src/app/(main)/bucket/[id]/settings/) diff --git a/apps/management-web/src/app/(main)/admin/[id]/edit/page.tsx b/apps/management-web/src/app/(main)/admin/[id]/edit/page.tsx index 2bb214d6..575fae11 100644 --- a/apps/management-web/src/app/(main)/admin/[id]/edit/page.tsx +++ b/apps/management-web/src/app/(main)/admin/[id]/edit/page.tsx @@ -6,12 +6,14 @@ import { getTranslations } from 'next-intl/server'; import { notFound, redirect } from 'next/navigation'; import { request } from '@metaboost/helpers-requests'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; import { AdminForm } from '../../../../../components/admins/AdminForm'; +import { ManagementBreadcrumbLink } from '../../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../../components/ResourcePageCard'; import { getServerManagementApiBaseUrl } from '../../../../../config/env'; import { getCrudFlags } from '../../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../../lib/management-breadcrumbs'; import { ROUTES, adminViewRoute } from '../../../../../lib/routes'; import { getServerUser } from '../../../../../lib/server-auth'; import { getCookieHeader } from '../../../../../lib/server-request'; @@ -66,31 +68,15 @@ export default async function EditAdminPage({ params }: EditAdminPageProps) { const tCommon = await getTranslations('common'); const adminLabel = admin.displayName ?? admin.username; - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('admins'), href: ROUTES.ADMINS }, { label: adminLabel, href: adminViewRoute(id) }, { label: tCommon('edit'), href: undefined }, - ]; - - function BreadcrumbLink({ - href, - children, - className, - }: { - href: string; - children: React.ReactNode; - className?: string; - }) { - return ( - - {children} - - ); - } + ]); return ( } + breadcrumbs={} contentMaxWidth="form" > diff --git a/apps/management-web/src/app/(main)/admin/[id]/page.tsx b/apps/management-web/src/app/(main)/admin/[id]/page.tsx index c6e75e37..7d29b788 100644 --- a/apps/management-web/src/app/(main)/admin/[id]/page.tsx +++ b/apps/management-web/src/app/(main)/admin/[id]/page.tsx @@ -1,16 +1,26 @@ import type { ManagementUser } from '../../../../types/management-api'; import type { CrudBit } from '@metaboost/helpers'; +import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { notFound, redirect } from 'next/navigation'; import { bitmaskToFlags } from '@metaboost/helpers'; import { request } from '@metaboost/helpers-requests'; -import { ButtonLink, FormActions, Stack, Text } from '@metaboost/ui'; +import { + Breadcrumbs, + ButtonLink, + ContentPageLayout, + FormActions, + Stack, + Text, +} from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../components/ResourcePageCard'; import { getServerManagementApiBaseUrl } from '../../../../config/env'; import { getCrudFlags, hasReadPermission } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES, adminEditRoute } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; import { getCookieHeader } from '../../../../lib/server-request'; @@ -77,46 +87,58 @@ export default async function ViewAdminPage({ params }: ViewAdminPageProps) { return set.length > 0 ? set.join(', ') : '—'; } + const adminLabel = admin.displayName ?? admin.username; + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('admins'), href: ROUTES.ADMINS }, + { label: adminLabel, href: undefined }, + ]); + return ( - } + contentMaxWidth="form" > - - - {tCommon('adminsTable.displayName')}: {admin.displayName ?? '—'} - - - {tCommon('adminsTable.username')}: {admin.username} - - {admin.isSuperAdmin !== true && - admin.permissions !== undefined && - admin.permissions !== null && ( - <> - - {tForm('adminsCrud')}:{' '} - {formatCrudFlags(admin.permissions.adminsCrud)} - - - {tForm('usersCrud')}:{' '} - {formatCrudFlags(admin.permissions.usersCrud)} - - - {tForm('eventVisibility')}: {admin.permissions.eventVisibility} - - - )} - {admin.isSuperAdmin === true && {tCommon('viewAdminSuperAdminNote')}} - - - {tCommon('adminForm.cancel')} - - {canEdit && ( - - {tCommon('adminsTable.edit')} + + + + {tCommon('adminsTable.displayName')}: {admin.displayName ?? '—'} + + + {tCommon('adminsTable.username')}: {admin.username} + + {admin.isSuperAdmin !== true && + admin.permissions !== undefined && + admin.permissions !== null && ( + <> + + {tForm('adminsCrud')}:{' '} + {formatCrudFlags(admin.permissions.adminsCrud)} + + + {tForm('usersCrud')}:{' '} + {formatCrudFlags(admin.permissions.usersCrud)} + + + {tForm('eventVisibility')}: {admin.permissions.eventVisibility} + + + )} + {admin.isSuperAdmin === true && {tCommon('viewAdminSuperAdminNote')}} + + + {tCommon('adminForm.cancel')} - )} - - - + {canEdit && ( + + {tCommon('adminsTable.edit')} + + )} + + + + ); } diff --git a/apps/management-web/src/app/(main)/admins/new/page.tsx b/apps/management-web/src/app/(main)/admins/new/page.tsx index 2929b27a..28911144 100644 --- a/apps/management-web/src/app/(main)/admins/new/page.tsx +++ b/apps/management-web/src/app/(main)/admins/new/page.tsx @@ -3,30 +3,16 @@ import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; import { AdminForm } from '../../../../components/admins/AdminForm'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../components/ResourcePageCard'; import { getCrudFlags } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; -function BreadcrumbLink({ - href, - children, - className, -}: { - href: string; - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - export default async function NewAdminPage() { const user = await getServerUser(); @@ -42,14 +28,14 @@ export default async function NewAdminPage() { const canEditPermissions = crud.create || crud.update; const tCommon = await getTranslations('common'); - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('admins'), href: ROUTES.ADMINS }, { label: tCommon('addAdminTitle'), href: undefined }, - ]; + ]); return ( } + breadcrumbs={} contentMaxWidth="form" > diff --git a/apps/management-web/src/app/(main)/admins/page.tsx b/apps/management-web/src/app/(main)/admins/page.tsx index bd49b52c..d38371ca 100644 --- a/apps/management-web/src/app/(main)/admins/page.tsx +++ b/apps/management-web/src/app/(main)/admins/page.tsx @@ -1,4 +1,5 @@ import type { ManagementUser } from '../../../types/management-api'; +import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { cookies } from 'next/headers'; @@ -7,6 +8,7 @@ import { redirect } from 'next/navigation'; import { DEFAULT_PAGE_LIMIT } from '@metaboost/helpers'; import { request } from '@metaboost/helpers-requests'; import { + Breadcrumbs, FilterTablePageLayout, getSortPrefsFromCookieValue, getTableListStateEntryFromCookieValue, @@ -14,9 +16,11 @@ import { } from '@metaboost/ui'; import { AdminsTableWithFilter } from '../../../components/AdminsTableWithFilter'; +import { ManagementBreadcrumbLink } from '../../../components/ManagementBreadcrumbLink'; import { getManagementApiBaseUrl, getServerManagementApiBaseUrl } from '../../../config/env'; import { TABLE_LIST_STATE_COOKIE_NAME, TABLE_SORT_PREFS_COOKIE_NAME } from '../../../lib/cookies'; import { getCrudFlags, hasReadPermission } from '../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../lib/management-breadcrumbs'; import { parseFilterColumns } from '../../../lib/parseFilterColumns'; import { ROUTES } from '../../../lib/routes'; import { getServerUser } from '../../../lib/server-auth'; @@ -134,6 +138,9 @@ export default async function AdminsPage({ searchParams }: PageProps) { : cookieSort?.sortOrder; const tCommon = await getTranslations('common'); + const listBreadcrumbs: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('admins'), href: undefined }, + ]); const { data, error } = await fetchAdmins( page, limit, @@ -177,6 +184,7 @@ export default async function AdminsPage({ searchParams }: PageProps) { return ( } title={tCommon('admins')} error={error !== null ? error : undefined} errorVariant="error" diff --git a/apps/management-web/src/app/(main)/admins/roles/new/page.tsx b/apps/management-web/src/app/(main)/admins/roles/new/page.tsx index 7c0871f5..804c2159 100644 --- a/apps/management-web/src/app/(main)/admins/roles/new/page.tsx +++ b/apps/management-web/src/app/(main)/admins/roles/new/page.tsx @@ -1,11 +1,16 @@ +import type { BreadcrumbItem } from '@metaboost/ui'; + import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; import { resolveReturnUrlFromQuery } from '@metaboost/helpers'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; import { AdminRoleForm } from '../../../../../components/admins/AdminRoleForm'; +import { ManagementBreadcrumbLink } from '../../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../../components/ResourcePageCard'; import { getCrudFlags } from '../../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../../lib/routes'; import { getServerUser } from '../../../../../lib/server-auth'; @@ -25,13 +30,23 @@ export default async function NewAdminRolePage({ const fallbackNavigationHref = ROUTES.ADMINS; const returnUrl = resolveReturnUrlFromQuery(resolvedSearch.returnUrl, fallbackNavigationHref); + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('admins'), href: ROUTES.ADMINS }, + { label: tCommon('addRoleTitle'), href: undefined }, + ]); + return ( - - - + } + contentMaxWidth="form" + > + + + + ); } diff --git a/apps/management-web/src/app/(main)/bucket/[id]/new/page.tsx b/apps/management-web/src/app/(main)/bucket/[id]/new/page.tsx index 7f6066a0..ed2f92a7 100644 --- a/apps/management-web/src/app/(main)/bucket/[id]/new/page.tsx +++ b/apps/management-web/src/app/(main)/bucket/[id]/new/page.tsx @@ -5,10 +5,12 @@ import { getTranslations } from 'next-intl/server'; import { redirect, notFound } from 'next/navigation'; import { request } from '@metaboost/helpers-requests'; -import { Breadcrumbs, Container, Link, SectionWithHeading } from '@metaboost/ui'; +import { Breadcrumbs, Container, SectionWithHeading } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../../components/ManagementBreadcrumbLink'; import { getServerManagementApiBaseUrl } from '../../../../../config/env'; import { getCrudFlags, hasReadPermission } from '../../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../../lib/management-breadcrumbs'; import { ROUTES, bucketViewRoute } from '../../../../../lib/routes'; import { getServerUser } from '../../../../../lib/server-auth'; import { getCookieHeader } from '../../../../../lib/server-request'; @@ -40,22 +42,6 @@ async function fetchBucketAncestry(bucket: ManagementBucket): Promise - {children} - - ); -} - export default async function NewChildBucketPage({ params }: { params: Promise<{ id: string }> }) { const user = await getServerUser(); if (user === null) redirect(ROUTES.LOGIN); @@ -74,17 +60,17 @@ export default async function NewChildBucketPage({ params }: { params: Promise<{ const ancestors = await fetchBucketAncestry(bucket); const tCommon = await getTranslations('common'); const parentHref = bucketViewRoute(bucket.idText); - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ ...ancestors.map((a) => ({ label: a.name, href: bucketViewRoute(a.idText) })), { label: bucket.name, href: bucketViewRoute(bucket.idText) }, { label: tCommon('bucketDetail.addBucket'), href: undefined }, - ]; + ]); return ( diff --git a/apps/management-web/src/app/(main)/bucket/[id]/page.tsx b/apps/management-web/src/app/(main)/bucket/[id]/page.tsx index 4e167123..90b9e288 100644 --- a/apps/management-web/src/app/(main)/bucket/[id]/page.tsx +++ b/apps/management-web/src/app/(main)/bucket/[id]/page.tsx @@ -25,6 +25,7 @@ import { TABLE_SORT_PREFS_COOKIE_NAME, } from '../../../../lib/cookies'; import { getCrudFlags, hasReadPermission } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../lib/routes'; import { bucketEditRoute, @@ -291,11 +292,15 @@ export default async function BucketDetailPage({ ? tCommon('bucketDetail.publicYes') : tCommon('bucketDetail.publicNo'), })); - const breadcrumbItems: BreadcrumbItem[] = ancestors.map((ancestor) => ({ + const ancestryTrail: BreadcrumbItem[] = ancestors.map((ancestor) => ({ label: ancestor.name, href: bucketViewRoute(ancestor.idText), })); const currentBreadcrumb: BreadcrumbItem = { label: bucket.name, href: undefined }; + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + ...ancestryTrail, + currentBreadcrumb, + ]); const publicPageHref = bucket.isPublic ? (() => { @@ -341,13 +346,11 @@ export default async function BucketDetailPage({ return ( 0 ? ( - - ) : undefined + } > ({ label: a.name, href: bucketViewRoute(a.idText), @@ -71,6 +74,7 @@ export default async function BucketSettingsLayout({ bucketId={id} bucketName={bucket.name} bucketSettingsTitle={t('bucketSettings')} + leadingBreadcrumbItems={leadingBreadcrumbItems} ancestorItems={ancestorItems} > {children} diff --git a/apps/management-web/src/app/(main)/buckets/new/page.tsx b/apps/management-web/src/app/(main)/buckets/new/page.tsx index e55d7e05..53f4ce32 100644 --- a/apps/management-web/src/app/(main)/buckets/new/page.tsx +++ b/apps/management-web/src/app/(main)/buckets/new/page.tsx @@ -5,12 +5,14 @@ import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; import { request } from '@metaboost/helpers-requests'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; import { BucketForm } from '../../../../components/buckets/BucketForm'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../components/ResourcePageCard'; import { getServerManagementApiBaseUrl } from '../../../../config/env'; import { getCrudFlags } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; import { getCookieHeader } from '../../../../lib/server-request'; @@ -53,30 +55,14 @@ export default async function NewBucketPage() { label: (u.displayName !== null && u.displayName !== '' ? u.displayName : u.email) ?? u.id, })); - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('buckets'), href: ROUTES.BUCKETS }, { label: tCommon('addBucketTitle'), href: undefined }, - ]; - - function BreadcrumbLink({ - href, - children, - className, - }: { - href: string; - children: React.ReactNode; - className?: string; - }) { - return ( - - {children} - - ); - } + ]); return ( } + breadcrumbs={} contentMaxWidth="form" > diff --git a/apps/management-web/src/app/(main)/buckets/page.tsx b/apps/management-web/src/app/(main)/buckets/page.tsx index f0444ce6..32156d1f 100644 --- a/apps/management-web/src/app/(main)/buckets/page.tsx +++ b/apps/management-web/src/app/(main)/buckets/page.tsx @@ -1,4 +1,5 @@ import type { ListBucketsData } from '@metaboost/helpers-requests'; +import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { cookies } from 'next/headers'; @@ -7,6 +8,7 @@ import { redirect } from 'next/navigation'; import { coerceFirstQueryString } from '@metaboost/helpers'; import { request } from '@metaboost/helpers-requests'; import { + Breadcrumbs, FilterTablePageLayout, getSortPrefsFromCookieValue, getTableListStateEntryFromCookieValue, @@ -14,9 +16,11 @@ import { } from '@metaboost/ui'; import { BucketsTableWithFilter } from '../../../components/BucketsTableWithFilter'; +import { ManagementBreadcrumbLink } from '../../../components/ManagementBreadcrumbLink'; import { getManagementApiBaseUrl, getServerManagementApiBaseUrl } from '../../../config/env'; import { TABLE_LIST_STATE_COOKIE_NAME, TABLE_SORT_PREFS_COOKIE_NAME } from '../../../lib/cookies'; import { getCrudFlags, hasReadPermission } from '../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../lib/routes'; import { getServerUser } from '../../../lib/server-auth'; import { getCookieHeader } from '../../../lib/server-request'; @@ -92,6 +96,9 @@ export default async function BucketsPage({ searchParams }: PageProps) { sortOrderParam === 'asc' || sortOrderParam === 'desc' ? sortOrderParam : cookieSort?.sortOrder; const tCommon = await getTranslations('common'); + const listBreadcrumbs: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('buckets'), href: undefined }, + ]); const { data, error } = await fetchBuckets(page, limit, search, sortBy, sortOrder); const buckets = data?.buckets ?? []; @@ -122,6 +129,7 @@ export default async function BucketsPage({ searchParams }: PageProps) { return ( } title={tCommon('buckets')} error={error !== null ? tCommon('failedToLoadBuckets') : undefined} errorVariant="error" diff --git a/apps/management-web/src/app/(main)/events/page.tsx b/apps/management-web/src/app/(main)/events/page.tsx index e8662ae9..419f99e4 100644 --- a/apps/management-web/src/app/(main)/events/page.tsx +++ b/apps/management-web/src/app/(main)/events/page.tsx @@ -1,3 +1,5 @@ +import type { BreadcrumbItem } from '@metaboost/ui'; + import { getLocale, getTranslations } from 'next-intl/server'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -6,16 +8,20 @@ import { DEFAULT_PAGE_LIMIT } from '@metaboost/helpers'; import { formatDateTimeReadable } from '@metaboost/helpers-i18n/client'; import { request } from '@metaboost/helpers-requests'; import { + Breadcrumbs, Container, getSortPrefsFromCookieValue, getTableListStateEntryFromCookieValue, SectionWithHeading, + Stack, Text, } from '@metaboost/ui'; import { EventsListClientSection } from '../../../components/EventsListClientSection'; +import { ManagementBreadcrumbLink } from '../../../components/ManagementBreadcrumbLink'; import { getServerManagementApiBaseUrl } from '../../../config/env'; import { TABLE_LIST_STATE_COOKIE_NAME, TABLE_SORT_PREFS_COOKIE_NAME } from '../../../lib/cookies'; +import { withDashboardBreadcrumb } from '../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../lib/routes'; import { getServerUser } from '../../../lib/server-auth'; import { getCookieHeader } from '../../../lib/server-request'; @@ -155,6 +161,9 @@ export default async function EventsPage({ searchParams }: PageProps) { const locale = await getLocale(); const tCommon = await getTranslations('common'); + const listBreadcrumbs: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('events'), href: undefined }, + ]); const { data, error } = await fetchEvents( page, limit, @@ -210,37 +219,40 @@ export default async function EventsPage({ searchParams }: PageProps) { return ( - - {error !== null && ( - - {error} - - )} - {error === null && ( - - )} - + + + + {error !== null && ( + + {error} + + )} + {error === null && ( + + )} + + ); } diff --git a/apps/management-web/src/app/(main)/global-blocked-apps/page.tsx b/apps/management-web/src/app/(main)/global-blocked-apps/page.tsx index 933788f8..0ef24e04 100644 --- a/apps/management-web/src/app/(main)/global-blocked-apps/page.tsx +++ b/apps/management-web/src/app/(main)/global-blocked-apps/page.tsx @@ -1,8 +1,13 @@ +import type { BreadcrumbItem } from '@metaboost/ui'; + +import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; -import { Container } from '@metaboost/ui'; +import { Breadcrumbs, Container, Stack } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../components/ManagementBreadcrumbLink'; import { hasReadPermission } from '../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../lib/routes'; import { getServerUser } from '../../../lib/server-auth'; import { GlobalBlockedAppsClient } from './GlobalBlockedAppsClient'; @@ -17,9 +22,16 @@ export default async function GlobalBlockedAppsPage() { if (!canReadAdmins) { redirect(ROUTES.DASHBOARD); } + const tCommon = await getTranslations('common'); + const listBreadcrumbs: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('globalBlockedApps'), href: undefined }, + ]); return ( - + + + + ); } diff --git a/apps/management-web/src/app/(main)/products/membership/page.tsx b/apps/management-web/src/app/(main)/products/membership/page.tsx index 94b9e6a1..6195211f 100644 --- a/apps/management-web/src/app/(main)/products/membership/page.tsx +++ b/apps/management-web/src/app/(main)/products/membership/page.tsx @@ -3,29 +3,15 @@ import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { getCrudFlags, hasReadPermission } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; import { ProductsMembershipClient } from './ProductsMembershipClient'; -function BreadcrumbLink({ - href, - children, - className, -}: { - href: string; - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - export default async function ProductsMembershipPage() { const user = await getServerUser(); @@ -42,14 +28,13 @@ export default async function ProductsMembershipPage() { const crud = getCrudFlags(user.isSuperAdmin === true, user.permissions, 'billingPricesCrud'); const tCommon = await getTranslations('common'); const t = await getTranslations('billingGovernance'); - const breadcrumbItems: BreadcrumbItem[] = [ - { label: tCommon('dashboard'), href: ROUTES.DASHBOARD }, + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: t('pageTitle'), href: undefined }, - ]; + ]); return ( } + breadcrumbs={} title={t('pageTitle')} contentMaxWidth="readable" > diff --git a/apps/management-web/src/app/(main)/terms-version/[id]/edit/page.tsx b/apps/management-web/src/app/(main)/terms-version/[id]/edit/page.tsx index c6ee49d3..ea32b1cb 100644 --- a/apps/management-web/src/app/(main)/terms-version/[id]/edit/page.tsx +++ b/apps/management-web/src/app/(main)/terms-version/[id]/edit/page.tsx @@ -5,12 +5,14 @@ import { getTranslations } from 'next-intl/server'; import { notFound, redirect } from 'next/navigation'; import { request } from '@metaboost/helpers-requests'; -import { Breadcrumbs, ContentPageLayout, Link, Stack } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout, Stack } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../../components/ResourcePageCard'; import { TermsVersionActions } from '../../../../../components/terms-versions/TermsVersionActions'; import { TermsVersionForm } from '../../../../../components/terms-versions/TermsVersionForm'; import { getServerManagementApiBaseUrl } from '../../../../../config/env'; +import { withDashboardBreadcrumb } from '../../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../../lib/routes'; import { getServerUser } from '../../../../../lib/server-auth'; import { getCookieHeader } from '../../../../../lib/server-request'; @@ -37,22 +39,6 @@ async function fetchTermsVersion(id: string): Promise - {children} - - ); -} - export default async function EditTermsVersionPage({ params }: EditTermsVersionPageProps) { const user = await getServerUser(); if (user === null) { @@ -70,14 +56,14 @@ export default async function EditTermsVersionPage({ params }: EditTermsVersionP const tCommon = await getTranslations('common'); const itemLabel = `${termsVersion.versionKey} - ${termsVersion.title}`; const canEdit = termsVersion.status === 'draft' || termsVersion.status === 'upcoming'; - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('termsVersions'), href: ROUTES.TERMS_VERSIONS }, { label: itemLabel, href: undefined }, - ]; + ]); return ( } + breadcrumbs={} contentMaxWidth="form" > diff --git a/apps/management-web/src/app/(main)/terms-versions/new/page.tsx b/apps/management-web/src/app/(main)/terms-versions/new/page.tsx index 28bfb1e2..286c4e9c 100644 --- a/apps/management-web/src/app/(main)/terms-versions/new/page.tsx +++ b/apps/management-web/src/app/(main)/terms-versions/new/page.tsx @@ -3,29 +3,15 @@ import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../components/ResourcePageCard'; import { TermsVersionForm } from '../../../../components/terms-versions/TermsVersionForm'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; -function BreadcrumbLink({ - href, - children, - className, -}: { - href: string; - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - export default async function NewTermsVersionPage() { const user = await getServerUser(); if (user === null) { @@ -36,14 +22,14 @@ export default async function NewTermsVersionPage() { } const tCommon = await getTranslations('common'); - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('termsVersions'), href: ROUTES.TERMS_VERSIONS }, { label: tCommon('addTermsVersionTitle'), href: undefined }, - ]; + ]); return ( } + breadcrumbs={} contentMaxWidth="form" > diff --git a/apps/management-web/src/app/(main)/terms-versions/page.tsx b/apps/management-web/src/app/(main)/terms-versions/page.tsx index eff88891..894e1897 100644 --- a/apps/management-web/src/app/(main)/terms-versions/page.tsx +++ b/apps/management-web/src/app/(main)/terms-versions/page.tsx @@ -2,15 +2,18 @@ import type { ManagementTermsVersion, TermsVersionLifecycleStatus, } from '@metaboost/helpers-requests'; +import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; import { request } from '@metaboost/helpers-requests'; -import { FilterTablePageLayout, Stack } from '@metaboost/ui'; +import { Breadcrumbs, FilterTablePageLayout, Stack } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../components/ManagementBreadcrumbLink'; import { TermsVersionsTableWithFilter } from '../../../components/TermsVersionsTableWithFilter'; import { getManagementApiBaseUrl, getServerManagementApiBaseUrl } from '../../../config/env'; +import { withDashboardBreadcrumb } from '../../../lib/management-breadcrumbs'; import { parseFilterColumns } from '../../../lib/parseFilterColumns'; import { ROUTES } from '../../../lib/routes'; import { getServerUser } from '../../../lib/server-auth'; @@ -71,6 +74,9 @@ export default async function TermsVersionsPage({ searchParams }: PageProps) { const resolved = searchParams !== undefined ? await searchParams : {}; const tCommon = await getTranslations('common'); + const listBreadcrumbs: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('termsVersions'), href: undefined }, + ]); const { data, error } = await fetchTermsVersions(); const termsVersions = data?.termsVersions ?? []; @@ -178,6 +184,7 @@ export default async function TermsVersionsPage({ searchParams }: PageProps) { return ( } title={tCommon('termsVersions')} error={error !== null ? tCommon('failedToLoadTermsVersions') : undefined} errorVariant="error" diff --git a/apps/management-web/src/app/(main)/user/[id]/edit/page.tsx b/apps/management-web/src/app/(main)/user/[id]/edit/page.tsx index 22418167..aabdf182 100644 --- a/apps/management-web/src/app/(main)/user/[id]/edit/page.tsx +++ b/apps/management-web/src/app/(main)/user/[id]/edit/page.tsx @@ -11,11 +11,13 @@ import { toDateTimeLocalValue, } from '@metaboost/helpers'; import { request } from '@metaboost/helpers-requests'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../../components/ResourcePageCard'; import { getServerManagementApiBaseUrl } from '../../../../../config/env'; import { getCrudFlags } from '../../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../../lib/management-breadcrumbs'; import { ROUTES, userViewRoute } from '../../../../../lib/routes'; import { getServerUser } from '../../../../../lib/server-auth'; import { getCookieHeader } from '../../../../../lib/server-request'; @@ -44,22 +46,6 @@ async function fetchUser(id: string): Promise<{ user: MainAppUser } | null> { } } -function BreadcrumbLink({ - href, - children, - className, -}: { - href: string; - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - export default async function EditUserPage({ params, searchParams }: EditUserPageProps) { const user = await getServerUser(); @@ -96,15 +82,15 @@ export default async function EditUserPage({ params, searchParams }: EditUserPag const tCommon = await getTranslations('common'); const displayLabel = mainUser.displayName ?? mainUser.username ?? mainUser.email ?? id; - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('users'), href: ROUTES.USERS }, { label: displayLabel, href: userViewRoute(id) }, { label: tCommon('edit'), href: undefined }, - ]; + ]); return ( } + breadcrumbs={} contentMaxWidth="form" constrainMainOnly fullWidthAboveConstrained={} diff --git a/apps/management-web/src/app/(main)/user/[id]/page.tsx b/apps/management-web/src/app/(main)/user/[id]/page.tsx index 4993b515..993d2ad5 100644 --- a/apps/management-web/src/app/(main)/user/[id]/page.tsx +++ b/apps/management-web/src/app/(main)/user/[id]/page.tsx @@ -1,15 +1,25 @@ import type { MainAppUser } from '../../../../types/management-api'; +import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { notFound, redirect } from 'next/navigation'; import { MembershipTier, membershipTierFromStoredValue } from '@metaboost/helpers'; import { request } from '@metaboost/helpers-requests'; -import { ButtonLink, FormActions, Stack, Text } from '@metaboost/ui'; +import { + Breadcrumbs, + ButtonLink, + ContentPageLayout, + FormActions, + Stack, + Text, +} from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../components/ResourcePageCard'; import { getServerManagementApiBaseUrl } from '../../../../config/env'; import { getCrudFlags, hasReadPermission } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES, userEditRoute } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; import { getCookieHeader } from '../../../../lib/server-request'; @@ -66,39 +76,49 @@ export default async function ViewUserPage({ params }: ViewUserPageProps) { ? tCommon('userForm.membershipTierTrial') : tCommon('userForm.membershipStatusMembership'); + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('users'), href: ROUTES.USERS }, + { label: displayLabel, href: undefined }, + ]); + return ( - - - - {tCommon('usersTable.email')}: {mainUser.email ?? '—'} - - {mainUser.username !== null && - mainUser.username !== undefined && - mainUser.username !== '' && ( - - {tCommon('usersTable.username')}: {mainUser.username} - + } + contentMaxWidth="form" + > + + + + {tCommon('usersTable.email')}: {mainUser.email ?? '—'} + + {mainUser.username !== null && + mainUser.username !== undefined && + mainUser.username !== '' && ( + + {tCommon('usersTable.username')}: {mainUser.username} + + )} + + {tCommon('usersTable.displayName')}: {mainUser.displayName ?? '—'} + + + {tCommon('userForm.membershipStatusLabel')}: {membershipStatusLabel} + + {resolvedMembershipTier === MembershipTier.Trial && ( + {tCommon('userForm.membershipTrialLimitations')} )} - - {tCommon('usersTable.displayName')}: {mainUser.displayName ?? '—'} - - - {tCommon('userForm.membershipStatusLabel')}: {membershipStatusLabel} - - {resolvedMembershipTier === MembershipTier.Trial && ( - {tCommon('userForm.membershipTrialLimitations')} - )} - - - {tCommon('adminForm.cancel')} - - {crud.update && ( - - {tCommon('usersTable.edit')} + + + {tCommon('adminForm.cancel')} - )} - - - + {crud.update && ( + + {tCommon('usersTable.edit')} + + )} + + + + ); } diff --git a/apps/management-web/src/app/(main)/users/new/page.tsx b/apps/management-web/src/app/(main)/users/new/page.tsx index e8566be9..8dbea350 100644 --- a/apps/management-web/src/app/(main)/users/new/page.tsx +++ b/apps/management-web/src/app/(main)/users/new/page.tsx @@ -3,30 +3,16 @@ import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { redirect } from 'next/navigation'; -import { Breadcrumbs, ContentPageLayout, Link } from '@metaboost/ui'; +import { Breadcrumbs, ContentPageLayout } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../../components/ManagementBreadcrumbLink'; import { ResourcePageCard } from '../../../../components/ResourcePageCard'; import { UserForm } from '../../../../components/users/UserForm'; import { getCrudFlags } from '../../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../../lib/management-breadcrumbs'; import { ROUTES } from '../../../../lib/routes'; import { getServerUser } from '../../../../lib/server-auth'; -function BreadcrumbLink({ - href, - children, - className, -}: { - href: string; - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - export default async function NewUserPage() { const user = await getServerUser(); @@ -40,14 +26,14 @@ export default async function NewUserPage() { } const tCommon = await getTranslations('common'); - const breadcrumbItems: BreadcrumbItem[] = [ + const breadcrumbItems: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ { label: tCommon('users'), href: ROUTES.USERS }, { label: tCommon('addUserTitle'), href: undefined }, - ]; + ]); return ( } + breadcrumbs={} contentMaxWidth="form" > diff --git a/apps/management-web/src/app/(main)/users/page.tsx b/apps/management-web/src/app/(main)/users/page.tsx index 563747ea..a76c891c 100644 --- a/apps/management-web/src/app/(main)/users/page.tsx +++ b/apps/management-web/src/app/(main)/users/page.tsx @@ -1,4 +1,5 @@ import type { MainAppUser } from '../../../types/management-api'; +import type { BreadcrumbItem } from '@metaboost/ui'; import { getTranslations } from 'next-intl/server'; import { cookies } from 'next/headers'; @@ -6,16 +7,19 @@ import { redirect } from 'next/navigation'; import { request } from '@metaboost/helpers-requests'; import { + Breadcrumbs, FilterTablePageLayout, getSortPrefsFromCookieValue, getTableListStateEntryFromCookieValue, Stack, } from '@metaboost/ui'; +import { ManagementBreadcrumbLink } from '../../../components/ManagementBreadcrumbLink'; import { UsersTableWithFilter } from '../../../components/UsersTableWithFilter'; import { getManagementApiBaseUrl, getServerManagementApiBaseUrl } from '../../../config/env'; import { TABLE_LIST_STATE_COOKIE_NAME, TABLE_SORT_PREFS_COOKIE_NAME } from '../../../lib/cookies'; import { getCrudFlags, hasReadPermission } from '../../../lib/main-nav'; +import { withDashboardBreadcrumb } from '../../../lib/management-breadcrumbs'; import { parseFilterColumns } from '../../../lib/parseFilterColumns'; import { ROUTES } from '../../../lib/routes'; import { getServerUser } from '../../../lib/server-auth'; @@ -119,6 +123,9 @@ export default async function UsersPage({ searchParams }: PageProps) { : cookieSort?.sortOrder; const tCommon = await getTranslations('common'); + const listBreadcrumbs: BreadcrumbItem[] = withDashboardBreadcrumb(tCommon('dashboard'), [ + { label: tCommon('users'), href: undefined }, + ]); const { data, error } = await fetchUsers(search, effectiveFilterColumns, sortBy, sortOrder); const users = data?.users ?? []; @@ -151,6 +158,7 @@ export default async function UsersPage({ searchParams }: PageProps) { return ( } title={tCommon('users')} error={error !== null ? tCommon('failedToLoadUsers') : undefined} errorVariant="error" diff --git a/apps/management-web/src/components/ManagementBreadcrumbLink.tsx b/apps/management-web/src/components/ManagementBreadcrumbLink.tsx new file mode 100644 index 00000000..ca838a26 --- /dev/null +++ b/apps/management-web/src/components/ManagementBreadcrumbLink.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Link } from '@metaboost/ui'; + +export function ManagementBreadcrumbLink({ + href, + children, + className, +}: { + href: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/apps/management-web/src/lib/management-breadcrumbs.ts b/apps/management-web/src/lib/management-breadcrumbs.ts new file mode 100644 index 00000000..119700cb --- /dev/null +++ b/apps/management-web/src/lib/management-breadcrumbs.ts @@ -0,0 +1,11 @@ +import type { BreadcrumbItem } from '@metaboost/ui'; + +import { ROUTES } from './routes'; + +/** Prepends a link to the management Dashboard as the first breadcrumb segment. */ +export function withDashboardBreadcrumb( + dashboardLabel: string, + items: BreadcrumbItem[] +): BreadcrumbItem[] { + return [{ label: dashboardLabel, href: ROUTES.DASHBOARD }, ...items]; +} diff --git a/packages/ui/src/components/bucket/BucketMessagesBreadcrumbs/BucketMessagesBreadcrumbs.tsx b/packages/ui/src/components/bucket/BucketMessagesBreadcrumbs/BucketMessagesBreadcrumbs.tsx index 5080188c..821f3cd7 100644 --- a/packages/ui/src/components/bucket/BucketMessagesBreadcrumbs/BucketMessagesBreadcrumbs.tsx +++ b/packages/ui/src/components/bucket/BucketMessagesBreadcrumbs/BucketMessagesBreadcrumbs.tsx @@ -5,6 +5,8 @@ import type { BreadcrumbItem } from '@metaboost/ui'; import { Breadcrumbs, Link } from '@metaboost/ui'; export type BucketMessagesBreadcrumbsProps = { + /** Optional segments before ancestry (e.g. Dashboard). */ + leadingItems?: BreadcrumbItem[]; /** Optional parent buckets in hierarchy order (root first). When set, shown before the current bucket. */ ancestorItems?: BreadcrumbItem[]; bucketName: string; @@ -31,6 +33,7 @@ function LinkAdapter({ } export function BucketMessagesBreadcrumbs({ + leadingItems = [], ancestorItems = [], bucketName, bucketDetailHref, @@ -38,6 +41,7 @@ export function BucketMessagesBreadcrumbs({ messagesAriaLabel, }: BucketMessagesBreadcrumbsProps) { const items: BreadcrumbItem[] = [ + ...leadingItems, ...ancestorItems, { label: bucketName, href: bucketDetailHref }, { label: currentPageLabel, href: undefined }, diff --git a/packages/ui/src/components/bucket/BucketMessagesPageContent/BucketMessagesPageContent.tsx b/packages/ui/src/components/bucket/BucketMessagesPageContent/BucketMessagesPageContent.tsx index 24f1e10b..76f7774b 100644 --- a/packages/ui/src/components/bucket/BucketMessagesPageContent/BucketMessagesPageContent.tsx +++ b/packages/ui/src/components/bucket/BucketMessagesPageContent/BucketMessagesPageContent.tsx @@ -12,6 +12,8 @@ import { BucketMessagesBreadcrumbs } from '../BucketMessagesBreadcrumbs'; * Renders breadcrumbs + title + message list only. No "Add message" — that is only on the public bucket page. */ export type BucketMessagesPageContentProps = { + /** Optional segments before ancestry in breadcrumbs (e.g. Dashboard). */ + leadingBreadcrumbItems?: BreadcrumbItem[]; /** Optional parent buckets in hierarchy order (root first) for breadcrumbs. */ ancestorItems?: BreadcrumbItem[]; bucketName: string; @@ -25,6 +27,7 @@ export type BucketMessagesPageContentProps = { }; export function BucketMessagesPageContent({ + leadingBreadcrumbItems = [], ancestorItems = [], bucketName, bucketDetailHref, @@ -39,6 +42,7 @@ export function BucketMessagesPageContent({ - - {error !== undefined && error !== null && error !== '' && ( - - {error} - - )} - {children} + + {breadcrumbs} + + {error !== undefined && error !== null && error !== '' && ( + + {error} + + )} + {children} + ); } From b4293f3fe82b369cbdd2460393a9374f7f3d9231 Mon Sep 17 00:00:00 2001 From: Mitch Downey Date: Sun, 21 Jun 2026 22:42:56 -0500 Subject: [PATCH 04/13] feat: enhance Cursor configuration and rules for improved development practices - Added new rules and documentation for managing app-internal import aliases, ensuring TypeScript path aliases match app directory names. - Introduced guidelines for extracting app-local UI wrappers to avoid repetition of `@metaboost/ui` configurations. - Established architecture tier dependency rules to prevent lower tiers from depending on higher tiers in the monorepo. - Implemented config and type safety rules, prohibiting default values in config files and allowing non-null assertions only in specific contexts. - Created documentation for CSS custom properties, emphasizing the avoidance of fallback arguments in SCSS/CSS. - Updated existing rules and added new ones for build order synchronization and E2E seed ID text limits. - Enhanced the overall structure and clarity of Cursor rules and documentation to support better developer workflows. --- .../exports/.state => .cursor/hooks}/.gitkeep | 0 .cursor/hooks/CURSOR-HOOKS.md | 7 + .../prompts}/.gitkeep | 0 .cursor/prompts/CURSOR-PROMPTS.md | 8 + .cursor/rules/CURSOR-RULES.md | 7 + .cursor/rules/abcmemory-vocabulary.mdc | 29 + .cursor/rules/app-internal-import-aliases.mdc | 38 + .cursor/rules/app-local-ui-wrappers.mdc | 16 + .../rules/architecture-tier-dependencies.mdc | 11 + .cursor/rules/build-order-doc-sync.mdc | 24 + .../rules/commands-from-metaboost-root.mdc | 1 + .cursor/rules/config-type-safety.mdc | 66 + ...css-custom-properties-no-var-fallbacks.mdc | 25 + .cursor/rules/documentation-updates.mdc | 15 + .cursor/rules/e2e-seed-id-text-limits.mdc | 22 + .../end-with-targeted-make-report-verify.mdc | 40 +- .cursor/rules/env-file-formatting.mdc | 15 +- .cursor/rules/github-actions-yaml.mdc | 76 + .cursor/rules/import-specifiers-tiered.mdc | 19 + .cursor/rules/infra-k8s.mdc | 43 + .cursor/rules/llm-cursor-source.mdc | 16 +- .cursor/rules/llm-exports-ci.mdc | 13 - .cursor/rules/llm-history-tracking.mdc | 31 - .cursor/rules/markdown-formatting.mdc | 10 + .cursor/rules/openapi-sync.mdc | 43 + .../rules/operator-only-git-operations.mdc | 41 + .cursor/rules/plan-creation.mdc | 4 +- .../plan-execution-completion-tracking.mdc | 6 + .../rules/prefer-shared-ui-web-management.mdc | 33 + .cursor/rules/shared-ui-i18n.mdc | 27 + .../rules/startup-validation-env-order.mdc | 21 + .cursor/rules/test-timeout-budget.mdc | 33 + .cursor/skills/CURSOR-SKILLS.md | 7 + .cursor/skills/INDEX.md | 42 +- .cursor/skills/abcmemory/SKILL.md | 92 + .cursor/skills/api/SKILL.md | 2 +- .cursor/skills/argocd-gitops-push/SKILL.md | 8 +- .cursor/skills/build-order/SKILL.md | 50 + .../docker-runtime-workspace-parity/SKILL.md | 60 + .../skills/documentation-conventions/SKILL.md | 21 + .cursor/skills/e2e-page-tests/SKILL.md | 6 +- .cursor/skills/e2e-report-order/SKILL.md | 2 +- .../skills/e2e-seed-nano-id-limits/SKILL.md | 58 + .../skills/env-defaults-match-code/SKILL.md | 50 + .cursor/skills/env-file-formatting/SKILL.md | 38 +- .cursor/skills/generate-data-sync/SKILL.md | 2 +- .cursor/skills/git-worktree-sibling/SKILL.md | 107 + .cursor/skills/github/SKILL.md | 83 + .cursor/skills/global/SKILL.md | 10 +- .../skills/import-specifiers-tiered/SKILL.md | 26 + .cursor/skills/k8s/SKILL.md | 22 +- .../skills/linear-baseline-gz-sync/SKILL.md | 2 +- .cursor/skills/linear-db-migrations/SKILL.md | 8 +- .cursor/skills/llm-cursor-source/SKILL.md | 29 +- .cursor/skills/llm-exports-scripts/SKILL.md | 32 - .cursor/skills/llm-history/SKILL.md | 73 - .cursor/skills/logging/SKILL.md | 36 + .../management-post-save-navigation/SKILL.md | 39 + .cursor/skills/modal-layout-contract/SKILL.md | 42 + .../native-deps-platform-mismatch/SKILL.md | 48 + .cursor/skills/observability/SKILL.md | 51 + .../skills/parallel-plan-execution/SKILL.md | 137 ++ .cursor/skills/plan-completion/SKILL.md | 79 + .cursor/skills/plan-files-convention/SKILL.md | 10 +- .cursor/skills/rate-limit-message/SKILL.md | 44 + .cursor/skills/release-changelog/SKILL.md | 32 - .../response-ending-make-verify/SKILL.md | 14 +- .cursor/skills/reusable-components/SKILL.md | 82 +- .cursor/skills/routing-url-params/SKILL.md | 49 + .../startup-validation-env-order/SKILL.md | 54 + .../skills/storybook-component-docs/SKILL.md | 2 +- .cursor/skills/styles-import-last/SKILL.md | 25 + .../skills/styles-source-of-truth/SKILL.md | 49 + .cursor/skills/time-format-local/SKILL.md | 32 + .../skills/ui-component-promotion/SKILL.md | 28 + .../skills/ui-e2e-screenshot-report/SKILL.md | 54 + .../SKILL.md | 40 + .../skills/unit-test-new-code-gate/SKILL.md | 42 + .../unit-test-priority-confident/SKILL.md | 40 + .../unit-tests-confident-granularity/SKILL.md | 3 + .cursor/skills/unit-tests-risk-first/SKILL.md | 4 + .cursorignore | 5 +- .cursorrules | 76 +- .github/dependabot.yml | 46 +- .github/workflows/ci.yml | 34 +- .github/workflows/complete-feature.yml | 100 + .github/workflows/i18n.yml | 3 + .github/workflows/llm-exports-full-sync.yml | 82 - .../llm-exports-optional-cloud-llm.yml | 14 - .github/workflows/llm-exports-sync.yml | 85 - .github/workflows/pr-labeler.yml | 3 +- .github/workflows/publish-main.yml | 12 +- .../workflows/publish-metaboost-signing.yml | 18 +- .github/workflows/publish-staging.yml | 57 +- .github/workflows/vulnerability-scanner.yml | 347 ++++ .gitignore | 17 - .llm/LLM.md | 95 +- .llm/{exports/opencode => context}/.gitkeep | 0 .llm/context/LLM-CONTEXT.md | 10 + .llm/context/architecture.md | 83 + .llm/context/conventions.md | 121 ++ .llm/exports/LLM-EXPORTS.md | 36 - .../github-copilot/copilot-instructions.md | 76 - ...i-credentials-in-responses.instructions.md | 24 - .../avoid-type-assertions.instructions.md | 10 - ...mmands-from-metaboost-root.instructions.md | 28 - .../e2e-run-with-make-only.instructions.md | 24 - .../e2e-suite-title.instructions.md | 16 - ...argeted-make-report-verify.instructions.md | 39 - .../env-file-formatting.instructions.md | 18 - .../eqeqeq-strict-equality.instructions.md | 15 - .../i18n-locale-language.instructions.md | 12 - .../llm-cursor-source.instructions.md | 23 - .../llm-exports-ci.instructions.md | 12 - .../llm-history-tracking.instructions.md | 29 - .../markdown-formatting.instructions.md | 12 - .../nix-terminal-wrapper.instructions.md | 26 - .../path-casing-imports.instructions.md | 19 - .../plan-creation.instructions.md | 14 - ...cution-completion-tracking.instructions.md | 16 - .../semicolons-required.instructions.md | 8 - .../single-readme.instructions.md | 13 - ...type-imports-separate-line.instructions.md | 30 - .../skills/api-testing/SKILL.md | 57 - .../github-copilot/skills/api/SKILL.md | 59 - .../skills/argocd-gitops-push/SKILL.md | 42 - .../skills/avoid-line-height/SKILL.md | 30 - .../skills/avoid-unknown-types/SKILL.md | 32 - .../skills/avoid-unused-props-vars/SKILL.md | 25 - .../skills/avoid-wrapper-elements/SKILL.md | 43 - .../skills/button-loading-async/SKILL.md | 31 - .../skills/catch-unused-error/SKILL.md | 35 - .../skills/crud-tables-resources/SKILL.md | 67 - .../skills/database-schema-naming/SKILL.md | 26 - .../skills/documentation-conventions/SKILL.md | 31 - .../skills/e2e-authz-matrix/SKILL.md | 42 - .../skills/e2e-crud-state-matrix/SKILL.md | 54 - .../skills/e2e-page-tests/SKILL.md | 117 -- .../e2e-permission-actor-matrix/SKILL.md | 76 - .../skills/e2e-readability/SKILL.md | 125 -- .../skills/e2e-report-order/SKILL.md | 45 - .../e2e-screenshot-verified-element/SKILL.md | 23 - .../skills/e2e-url-state-contracts/SKILL.md | 37 - .../feature-implementation-testing/SKILL.md | 29 - .../skills/focus-outline-visible/SKILL.md | 33 - .../skills/generate-data-sync/SKILL.md | 47 - .../github-copilot/skills/global/SKILL.md | 60 - .../helpers-requests-req-pattern/SKILL.md | 71 - .../github-copilot/skills/i18n/SKILL.md | 99 - .../skills/interactive-prompts/SKILL.md | 21 - .../skills/linear-db-migrations/SKILL.md | 60 - .../skills/llm-cursor-source/SKILL.md | 44 - .../skills/llm-exports-scripts/SKILL.md | 33 - .../skills/llm-history/SKILL.md | 74 - .../local-docker-k3d-alignment/SKILL.md | 38 - .../management-edit-breadcrumbs/SKILL.md | 32 - .../skills/management-edit-page-tabs/SKILL.md | 27 - .../nested-resource-prefix-naming/SKILL.md | 32 - .../github-copilot/skills/npm-audit/SKILL.md | 223 --- .../password-strength-on-set-update/SKILL.md | 32 - .../skills/path-casing-imports/SKILL.md | 25 - .../skills/plan-files-convention/SKILL.md | 74 - .../skills/prefer-named-exports/SKILL.md | 36 - .../skills/release-changelog/SKILL.md | 33 - .../response-ending-make-verify/SKILL.md | 77 - .../skills/reusable-components/SKILL.md | 26 - .../skills/roles-schema-sync/SKILL.md | 41 - .../skills/single-focus-indicator/SKILL.md | 24 - .../skills/single-readme/SKILL.md | 27 - .../skills/sort-prefs-cookie-by-path/SKILL.md | 38 - .../skills/storybook-component-docs/SKILL.md | 31 - .../skills/swagger-openapi/SKILL.md | 38 - .../tab-active-state-url-match/SKILL.md | 32 - .../skills/table-sort-defaults/SKILL.md | 76 - .../skills/tables-support-sorting/SKILL.md | 36 - .../typeorm-orderby-property-names/SKILL.md | 26 - .../unit-tests-confident-granularity/SKILL.md | 45 - .../skills/unit-tests-risk-first/SKILL.md | 45 - .../SKILL.md | 44 - .../skills/use-form-component/SKILL.md | 28 - .../github-copilot/skills/web/SKILL.md | 29 - ...i-credentials-in-responses.instructions.md | 24 - .../avoid-type-assertions.instructions.md | 10 - ...mmands-from-metaboost-root.instructions.md | 28 - .../e2e-run-with-make-only.instructions.md | 24 - .../e2e-suite-title.instructions.md | 16 - ...argeted-make-report-verify.instructions.md | 39 - .../env-file-formatting.instructions.md | 18 - .../eqeqeq-strict-equality.instructions.md | 15 - .../i18n-locale-language.instructions.md | 12 - .../llm-cursor-source.instructions.md | 23 - .../llm-exports-ci.instructions.md | 12 - .../llm-history-tracking.instructions.md | 29 - .../markdown-formatting.instructions.md | 12 - .../nix-terminal-wrapper.instructions.md | 26 - .../path-casing-imports.instructions.md | 19 - .../plan-creation.instructions.md | 14 - ...cution-completion-tracking.instructions.md | 16 - .../semicolons-required.instructions.md | 8 - .../single-readme.instructions.md | 13 - ...type-imports-separate-line.instructions.md | 30 - .../exports/opencode/opencode-instructions.md | 76 - .../opencode/skills/api-testing/SKILL.md | 57 - .llm/exports/opencode/skills/api/SKILL.md | 59 - .../skills/argocd-gitops-push/SKILL.md | 42 - .../skills/avoid-line-height/SKILL.md | 30 - .../skills/avoid-unknown-types/SKILL.md | 32 - .../skills/avoid-unused-props-vars/SKILL.md | 25 - .../skills/avoid-wrapper-elements/SKILL.md | 43 - .../skills/button-loading-async/SKILL.md | 31 - .../skills/catch-unused-error/SKILL.md | 35 - .../skills/crud-tables-resources/SKILL.md | 67 - .../skills/database-schema-naming/SKILL.md | 26 - .../skills/documentation-conventions/SKILL.md | 31 - .../opencode/skills/e2e-authz-matrix/SKILL.md | 42 - .../skills/e2e-crud-state-matrix/SKILL.md | 54 - .../opencode/skills/e2e-page-tests/SKILL.md | 117 -- .../e2e-permission-actor-matrix/SKILL.md | 76 - .../opencode/skills/e2e-readability/SKILL.md | 125 -- .../opencode/skills/e2e-report-order/SKILL.md | 45 - .../e2e-screenshot-verified-element/SKILL.md | 23 - .../skills/e2e-url-state-contracts/SKILL.md | 37 - .../feature-implementation-testing/SKILL.md | 29 - .../skills/focus-outline-visible/SKILL.md | 33 - .../skills/generate-data-sync/SKILL.md | 47 - .llm/exports/opencode/skills/global/SKILL.md | 60 - .../helpers-requests-req-pattern/SKILL.md | 71 - .llm/exports/opencode/skills/i18n/SKILL.md | 99 - .../skills/interactive-prompts/SKILL.md | 21 - .../skills/linear-db-migrations/SKILL.md | 60 - .../skills/llm-cursor-source/SKILL.md | 44 - .../skills/llm-exports-scripts/SKILL.md | 33 - .../opencode/skills/llm-history/SKILL.md | 74 - .../local-docker-k3d-alignment/SKILL.md | 38 - .../management-edit-breadcrumbs/SKILL.md | 32 - .../skills/management-edit-page-tabs/SKILL.md | 27 - .../nested-resource-prefix-naming/SKILL.md | 32 - .../opencode/skills/npm-audit/SKILL.md | 223 --- .../password-strength-on-set-update/SKILL.md | 32 - .../skills/path-casing-imports/SKILL.md | 25 - .../skills/plan-files-convention/SKILL.md | 74 - .../skills/prefer-named-exports/SKILL.md | 36 - .../skills/release-changelog/SKILL.md | 33 - .../response-ending-make-verify/SKILL.md | 77 - .../skills/reusable-components/SKILL.md | 26 - .../skills/roles-schema-sync/SKILL.md | 41 - .../skills/single-focus-indicator/SKILL.md | 24 - .../opencode/skills/single-readme/SKILL.md | 27 - .../skills/sort-prefs-cookie-by-path/SKILL.md | 38 - .../skills/storybook-component-docs/SKILL.md | 31 - .../opencode/skills/swagger-openapi/SKILL.md | 38 - .../tab-active-state-url-match/SKILL.md | 32 - .../skills/table-sort-defaults/SKILL.md | 76 - .../skills/tables-support-sorting/SKILL.md | 36 - .../typeorm-orderby-property-names/SKILL.md | 26 - .../unit-tests-confident-granularity/SKILL.md | 45 - .../skills/unit-tests-risk-first/SKILL.md | 45 - .../SKILL.md | 44 - .../skills/use-form-component/SKILL.md | 28 - .llm/exports/opencode/skills/web/SKILL.md | 29 - .llm/history/active/LLM-HISTORY-ACTIVE.md | 9 + .../cross-app-error-boundaries-part-01.md | 30 + .../css-var-no-fallbacks-part-01.md | 34 + .../fix-ebadplatform-npm-ci-part-01.md | 44 + .../local-docker-image-tag-pin-part-01.md | 6 +- ...boost-bucket-webpush-foundation-part-01.md | 197 ++ ...etaboost-staged-changes-cleanup-part-01.md | 224 +++ .../migrations-process-part-02.md | 8 +- ...uct-membership-metaboost-common-part-01.md | 28 + .../web-api-rollout-gating-part-01.md | 4 +- .../completed/LLM-HISTORY-COMPLETED.md | 7 + .llm/plans/active/LLM-PLANS-ACTIVE.md | 10 + .../00-EXECUTION-ORDER.md | 18 + .../00-SUMMARY.md | 22 + .../01-contract-parity.md | 12 + .../02-metaboost-runtime-and-theme-system.md | 13 + .../03-metaboost-e2e-matrix.md | 16 + .../04-abcmemory-and-doc-sync.md | 11 + .../COPY-PASTA.md | 29 + .llm/plans/completed/LLM-PLANS-COMPLETED.md | 6 + .../00-EXECUTION-ORDER.md | 2 +- .../01-reserve-version-job.md | 4 +- .../02-rewire-needs-and-outputs.md | 4 +- ...ve-git-tag-staging-and-validate-version.md | 4 +- .../04-docs-publish-update.md | 6 +- .../05-verification.md | 2 +- .../canonical-terms-bootstrap/00-SUMMARY.md | 8 +- .../01-infra-canonical-bootstrap.md | 12 +- .../00-SUMMARY.md | 10 + .../02-metaboost-docs-wrong-paths.md | 21 + .../03-metaboost-completed-plans-stale.md | 33 + .../04-verify-and-script-aliases.md | 33 + .../00-EXECUTION-ORDER.md | 15 + .../metaboost-abcmemory-align/00-SUMMARY.md | 34 + .../01-adopt-podverse-process-rules.md | 46 + .../02-port-drifted-shared-rules.md | 31 + .../03-port-drifted-shared-skills.md | 36 + .../04-align-cursorrules.md | 32 + .../metaboost-abcmemory-align/COPY-PASTA.md | 53 + .../00-EXECUTION-ORDER.md | 19 + .../01-backend-domain-and-schema.md | 35 + .../02-api-and-webpush-channel.md | 44 + .../03-web-ui-and-service-worker.md | 44 + .../04-env-vapid-and-local-setup.md | 42 + .../05-tests-and-verification.md | 52 + .../COPY-PASTA.md | 86 + .../00-EXECUTION-ORDER.md | 7 + .../00-SUMMARY.md | 22 + ...fix-eslint-stale-globs-and-commit-rules.md | 27 + .../02-complete-vitest-package-wiring.md | 28 + ...03-remove-lint-staged-from-package-json.md | 19 + .../COPY-PASTA.md | 28 + .../00-EXECUTION-ORDER.md | 8 + .../metaboost-ci-ops-align/00-SUMMARY.md | 23 + .../01-adopt-podverse-ci-workflows.md | 27 + .../02-align-ci-yml-gates.md | 34 + .../03-align-publish-and-dependabot.md | 26 + .../04-align-repo-management-docs.md | 29 + .../metaboost-ci-ops-align/COPY-PASTA.md | 32 + .../00-EXECUTION-ORDER.md | 7 + .../metaboost-dev-scripts-align/00-SUMMARY.md | 25 + .../01-align-git-hooks-with-podverse.md | 32 + .../02-sync-parity-dev-tooling-files.md | 22 + .../03-review-bump-version-audit-gate.md | 24 + .../metaboost-dev-scripts-align/COPY-PASTA.md | 26 + .../00-EXECUTION-ORDER.md | 7 + .../metaboost-infra-env-align/00-SUMMARY.md | 22 + .../01-sync-k8s-env-sources-with-templates.md | 31 + .../02-regenerate-linear-baseline-gz.md | 29 + ...-verify-alpha-product-membership-wiring.md | 26 + .../metaboost-infra-env-align/COPY-PASTA.md | 28 + .../00-EXECUTION-ORDER.md | 7 + .../00-SUMMARY.md | 19 + .../01-remove-stale-eslint-llm-glob.md | 33 + .../COPY-PASTA.md | 23 + .../00-EXECUTION-ORDER.md | 8 + .../00-SUMMARY.md | 23 + .../01-align-llm-root-and-dev-docs.md | 33 + .../02-align-llm-index-and-context.md | 22 + .../03-cleanup-llm-local.md | 24 + .../04-complete-feature-workflow-gap.md | 24 + .../COPY-PASTA.md | 38 + .../00-EXECUTION-ORDER.md | 89 + .../00-SUMMARY.md | 93 + .../01-llm-exports-removal-triage.md | 97 + .../02-changelog-removal-triage.md | 80 + .../03-cursor-abcmemory-triage.md | 149 ++ .../04-llm-workspace-docs-triage.md | 83 + .../05-ci-github-workflows-triage.md | 110 ++ .../06-k8s-alpha-infra-env-triage.md | 128 ++ .../07-git-hooks-dev-scripts-triage.md | 104 + .../08-build-package-config-triage.md | 111 ++ .../09-podverse-parity-inventory.md | 191 ++ .../COPY-PASTA.md | 181 ++ .../00-EXECUTION-ORDER.md | 14 +- .../00-SUMMARY.md | 32 +- .../01-env-overhaul.md | 6 +- .../05-linear-migrations.md | 4 +- .../06-k8s-base-alpha-appofapps.md | 4 +- .../00-EXECUTION-ORDER.md | 13 + .../00-SUMMARY.md | 39 + .../01-adopt-podverse-testing-standards.md | 34 + .../02-adopt-podverse-build-env-ops-skills.md | 33 + ...3-adopt-podverse-engineering-ops-skills.md | 34 + .../04-adopt-podverse-ui-shared-standards.md | 50 + .../05-adopt-run-workspaces-ci-script.md | 29 + .../COPY-PASTA.md | 42 + .../PROMOTE-TO-PODVERSE-NOTES.md | 46 + .../00-EXECUTION-ORDER.md | 39 + .../00-SUMMARY.md | 73 + .../01-error-boundaries-shared-ui.md | 86 + .../02-notification-dispatch-perf.md | 87 + .../03-shared-api-contract-types.md | 74 + .../04-schema-and-error-helpers.md | 134 ++ .../05-helpers-and-bell-refactor.md | 159 ++ .../06-style-and-import-hygiene.md | 80 + .../COPY-PASTA.md | 108 + .../00-EXECUTION-ORDER.md | 29 + .../remove-multi-llm-exports/00-SUMMARY.md | 46 + .../01-remove-scripts-and-ci.md | 52 + .../02-remove-exports-tree-and-gitignore.md | 43 + .../03-rewrite-cursor-guidance.md | 66 + .../04-revise-llm-docs.md | 43 + .../06-remote-and-operator-cleanup.md | 36 + .../remove-multi-llm-exports/COPY-PASTA.md | 72 + .../00-EXECUTION-ORDER.md | 2 +- .../s-endpoint-signing-rollout/00-SUMMARY.md | 2 +- .../10-npm-publish-verification-gate.md | 2 +- .../11-cross-repo-rollout-validation.md | 8 +- ...e-standard-endpoint-signing-integration.md | 6 +- .../00-EXECUTION-ORDER.md | 14 + .../00-SUMMARY.md | 35 + .../01-tier-boundaries-and-scope.md | 41 + .../02-verification-spike.md | 12 + .../03-eslint-enforcement.md | 33 + .../04-monorepo-sweep-matrix.md | 55 + .../05-documentation-skills-rules.md | 23 + .../06-future-convergence-todo.md | 3 + .../COPY-PASTA.md | 10 + .llm/templates/LLM-TEMPLATES.md | 9 + AGENTS.md | 58 +- README.md | 2 +- apps/api/.env.example | 9 +- apps/api/package.json | 7 +- apps/api/src/config/index.ts | 43 + apps/api/src/controllers/bucketsController.ts | 71 +- .../webPushSubscriptionsController.ts | 101 + apps/api/src/lib/logger.ts | 13 + apps/api/src/lib/message-threshold-filter.ts | 22 + .../notifications/notifyNewBucketMessage.ts | 31 + .../src/lib/notifications/webPushChannel.ts | 108 + .../lib/standardIngest/persistBoostMessage.ts | 7 + apps/api/src/middleware/auth.ts | 14 + apps/api/src/openapi.ts | 315 +++ apps/api/src/routes/auth.ts | 27 + apps/api/src/routes/buckets.ts | 13 + apps/api/src/schemas/buckets.ts | 18 + apps/api/src/schemas/webPushSubscriptions.ts | 29 + .../api/src/test/notification-webpush.test.ts | 464 +++++ apps/api/src/test/require-user.test.ts | 35 + apps/management-api/.env.example | 2 +- apps/management-web/i18n/originals/en-US.json | 10 +- apps/management-web/i18n/originals/es.json | 10 +- .../settings/BucketRolesClient.module.scss | 2 +- apps/management-web/src/app/error.tsx | 35 + apps/management-web/src/app/global-error.tsx | 55 + .../buckets/BucketMessagesClient.module.scss | 2 +- ...notifications-webpush-bucket-owner.spec.ts | 185 ++ apps/web/i18n/originals/en-US.json | 16 +- apps/web/i18n/originals/es.json | 16 +- apps/web/i18n/overrides/es.json | 16 +- apps/web/playwright.e2e-server-env.ts | 11 + apps/web/public/webpush-sw.js | 57 + apps/web/sidecar/.env.example | 5 + apps/web/sidecar/src/server.ts | 12 +- .../bucket/[id]/BucketDetailTabShell.tsx | 18 +- .../(main)/bucket/[id]/BucketName.module.scss | 16 + .../bucket/[id]/BucketNotificationsBell.tsx | 111 ++ .../bucket/[id]/BucketRolesClient.module.scss | 2 +- apps/web/src/app/(main)/bucket/[id]/page.tsx | 71 +- .../[id]/useBucketWebPushSubscription.ts | 116 ++ apps/web/src/app/error.tsx | 35 + apps/web/src/app/global-error.tsx | 55 + .../components/useApplyToDescendantsModal.tsx | 81 + apps/web/src/config/env.ts | 31 + apps/web/src/config/runtime-config.ts | 3 + apps/web/src/lib/buckets.ts | 104 +- apps/web/src/lib/server-request.ts | 17 + apps/web/src/lib/webPushBrowser.ts | 72 + docs/CURSOR-NIX-WITH-ENV.md | 6 +- docs/GITFLOW.md | 27 +- docs/PUBLISH.md | 25 +- docs/QUICK-START.md | 7 +- docs/api/METABOOST-SIGNING-DISTRIBUTION.md | 19 +- .../STANDARD-ENDPOINT-CONSUMER-EXAMPLES.md | 6 +- .../STANDARD-ENDPOINT-INTEGRATION-GUIDE.md | 6 +- docs/api/STANDARD-ENDPOINT-ROLLOUT-RUNBOOK.md | 4 +- docs/development/CHANGELOGS/0.1.10.md | 7 - docs/development/CHANGELOGS/0.1.11.md | 7 - docs/development/CHANGELOGS/0.1.12.md | 7 - docs/development/CHANGELOGS/0.1.13.md | 7 - docs/development/CHANGELOGS/0.1.14.md | 7 - docs/development/CHANGELOGS/0.1.15.md | 7 - docs/development/DEVELOPMENT.md | 1 - docs/development/NPM-AUDIT-ALLOWLIST.md | 85 +- docs/development/env/ENV-REFERENCE.md | 8 +- docs/development/env/LOCAL-ENV-OVERRIDES.md | 34 +- docs/development/env/WEB-PUSH-LOCAL.md | 33 + .../k8s/ARGOCD-GITOPS-METABOOST.md | 20 +- docs/development/k8s/K3D-ARGOCD-LOCAL.md | 2 +- docs/development/k8s/REMOTE-K8S-GITOPS.md | 12 +- docs/development/llm/CURSOR-COPILOT-SYNC.md | 7 - docs/development/llm/DOCS-DEVELOPMENT-LLM.md | 73 +- docs/development/llm/EXPORT-TARGETS.md | 44 - docs/development/llm/GH-EXPORTS-SETUP.md | 50 - .../llm/LLM-EDITOR-ALIGNMENT-PROMPT.md | 26 - .../llm/LLM-HISTORY-WORKFLOW-ARCHIVE.md | 245 +++ ...METABOOST-PUBLISH-GITOPS-BUMP-CHECKLIST.md | 10 +- .../release/STAGING-MAIN-PROMOTION.md | 6 +- ...-APPS-REFERENCE-ALIGNMENT-CHECKLIST-06B.md | 2 +- .../SECURITY-FINDINGS-CLOSURE-MATRIX.md | 24 +- .../security/SECURITY-REVIEW-CHECKLIST.md | 14 +- ...S-DEVELOPMENT-TOOLING-IMPORT-SPECIFIERS.md | 44 + docs/development/tooling/LOCKFILE-LINUX.md | 6 +- docs/localization/I18N.md | 2 +- .../DOCS-OPERATIONS-CHANGELOG-ARCHIVE.md | 9 - docs/operations/CHANGELOG-UPCOMING.md | 14 - docs/repo-management/BRANCH-PROTECTION.md | 15 +- docs/repo-management/DEPENDABOT.md | 9 +- docs/repo-management/GITHUB-LABELS.md | 14 +- docs/repo-management/GITHUB-SETUP.md | 32 +- docs/testing/E2E-PAGE-TESTING.md | 2 +- docs/testing/TEST-SETUP.md | 2 +- .../require-relative-js-extension.mjs | 157 ++ eslint.config.mjs | 53 +- infra/INFRA.md | 8 +- infra/config/env-templates/api.env.example | 6 +- .../env-templates/management-api.env.example | 2 +- .../env-templates/web-sidecar.env.example | 3 + infra/docker/local/INFRA-DOCKER-LOCAL.md | 2 +- infra/k8s/INFRA-K8S-BASE.md | 22 +- infra/k8s/INFRA-K8S.md | 10 +- infra/k8s/alpha-application.yaml | 2 +- infra/k8s/alpha/api/kustomization.yaml | 3 +- infra/k8s/alpha/apps/INFRA-K8S-ALPHA-APPS.md | 9 +- infra/k8s/alpha/apps/api.yaml | 2 +- infra/k8s/alpha/apps/common.yaml | 2 +- infra/k8s/alpha/apps/db.yaml | 2 +- infra/k8s/alpha/apps/keyvaldb.yaml | 2 +- infra/k8s/alpha/apps/management-api.yaml | 2 +- infra/k8s/alpha/apps/management-web.yaml | 2 +- infra/k8s/alpha/apps/ops.yaml | 2 +- infra/k8s/alpha/apps/web.yaml | 2 +- infra/k8s/alpha/common/kustomization.yaml | 5 + infra/k8s/alpha/db/kustomization.yaml | 2 +- infra/k8s/alpha/keyvaldb/kustomization.yaml | 2 +- .../alpha/management-api/kustomization.yaml | 3 +- .../alpha/management-web/kustomization.yaml | 2 +- infra/k8s/alpha/ops/kustomization.yaml | 2 +- infra/k8s/alpha/web/kustomization.yaml | 2 +- infra/k8s/base/api/source/api.env | 6 +- .../base/db/source/app/0001_app_schema.sql | 39 + .../0003a_app_linear_baseline.sql.gz | Bin 9197 -> 9640 bytes .../management-api/source/management-api.env | 2 +- .../source/management-web-sidecar.env | 5 +- .../linear-migrations/app/0001_app_schema.sql | 39 + .../source/product-membership-settings.env | 1 + infra/k8s/base/web/source/web-sidecar.env | 5 +- .../INFRA-K8S-SCRIPTS-SECRET-GENERATORS.md | 4 +- makefiles/local/e2e-spec-order-web.txt | 1 + package-lock.json | 1751 ++++++++++------- package.json | 11 +- packages/helpers-requests/package.json | 6 +- packages/helpers-requests/src/index.ts | 22 +- .../src/request.cookieHeaderToHeaders.test.ts | 17 + packages/helpers-requests/src/request.ts | 8 + packages/helpers-requests/src/web/auth.ts | 86 + packages/helpers-requests/src/web/buckets.ts | 96 +- packages/helpers-requests/vitest.config.ts | 8 + packages/metaboost-signing/CHANGELOG.md | 32 - .../PACKAGES-METABOOST-SIGNING.md | 10 +- packages/metaboost-signing/package.json | 1 - packages/orm/package.json | 4 +- packages/orm/src/data-source.ts | 4 + .../entities/BucketNotificationPreference.ts | 41 + .../src/entities/UserWebPushSubscription.ts | 44 + packages/orm/src/index.ts | 5 + packages/orm/src/lib/pgError.test.ts | 32 + packages/orm/src/lib/pgError.ts | 15 + .../BucketNotificationPreferenceService.ts | 126 ++ packages/orm/src/services/BucketService.ts | 19 +- .../UserWebPushSubscriptionService.ts | 156 ++ packages/orm/tsconfig.json | 2 +- packages/orm/vitest.config.ts | 10 + packages/ui/.storybook/preview.tsx | 2 +- packages/ui/package.json | 7 +- .../BucketDetailContent.tsx | 4 +- .../BucketMessagesPageContent.tsx | 10 +- .../bucket/MessageCard/MessageCard.tsx | 2 +- .../AppErrorBoundary.module.scss | 29 + .../AppErrorBoundary.stories.tsx | 59 + .../AppErrorBoundary.test.tsx | 75 + .../AppErrorBoundary/AppErrorBoundary.tsx | 65 + .../GlobalErrorBoundary.module.scss | 29 + .../GlobalErrorBoundary.stories.tsx | 52 + .../GlobalErrorBoundary.tsx | 87 + .../ForgotPasswordForm/ForgotPasswordForm.tsx | 12 +- .../form/AuthForms/LoginForm/LoginForm.tsx | 8 +- .../ResetPasswordForm/ResetPasswordForm.tsx | 10 +- .../form/AuthForms/SignupForm/SignupForm.tsx | 10 +- .../ui/src/components/form/AuthForms/index.ts | 16 +- .../components/form/ButtonLink/ButtonLink.tsx | 2 +- .../components/form/CopyButton/CopyButton.tsx | 2 +- .../form/CrudButtons/CrudButtons.tsx | 4 +- .../ui/src/components/form/Form/Form/Form.tsx | 6 +- .../form/Form/FormLinks/FormLinks.stories.tsx | 2 +- .../form/Form/FormLinks/FormLinks.tsx | 2 +- packages/ui/src/components/form/Form/index.ts | 12 +- .../form/FormContainer/FormContainer.tsx | 2 +- .../BucketDetailPageLayout.tsx | 2 +- .../layout/CodeSnippetBox/CodeSnippetBox.tsx | 6 +- .../components/layout/Container/Container.tsx | 2 +- .../ContentPageLayout/ContentPageLayout.tsx | 8 +- .../layout/CopyLinkBox/CopyLinkBox.tsx | 4 +- .../layout/DataDetail/DataDetail.tsx | 2 +- .../FilterTablePageLayout.tsx | 8 +- .../SectionWithHeading/SectionWithHeading.tsx | 2 +- .../ConfirmDeleteModal/ConfirmDeleteModal.tsx | 4 +- .../components/modal/Modal/Modal.stories.tsx | 4 +- .../modal/Modal/NavigationLoadingOverlay.tsx | 2 +- .../navigation/Dropdown/Dropdown.tsx | 2 +- .../components/navigation/NavBar/NavBar.tsx | 4 +- .../ThemeSelector/ThemeSelector.stories.tsx | 2 +- .../ThemeSelector/ThemeSelector.tsx | 4 +- .../ResourceTableWithFilter.tsx | 14 +- .../table/TableFilterBar/TableFilterBar.tsx | 2 +- .../table/TableWithFilter/TableWithFilter.tsx | 6 +- packages/ui/src/index.ts | 299 +-- packages/ui/src/lib/errorBoundaryStrings.ts | 32 + packages/ui/vitest.config.ts | 10 + scripts/ci/run-workspaces.mjs | 207 ++ .../generate-webpush-vapid-keys.sh | 12 + .../development/normalize-markdown-links.mjs | 310 +++ .../home-override-env-files.inc.sh | 3 +- .../write-home-override-stubs.rb | 6 + scripts/git-hooks/install-hooks.sh | 25 +- scripts/git-hooks/pre-commit | 38 - scripts/git-hooks/pre-push | 6 +- scripts/github/SCRIPTS-GITHUB.md | 13 +- scripts/github/setup-all-labels.sh | 12 +- scripts/llm/allowed-targets.mjs | 21 - scripts/llm/export-from-cursor.mjs | 138 -- scripts/llm/guard-exports-prompt.sh | 46 - scripts/llm/lib/copilot-adapter.mjs | 20 - scripts/llm/lib/github-style-adapter.mjs | 344 ---- scripts/llm/lib/opencode-adapter.mjs | 20 - scripts/llm/vendor-config.mjs | 66 - scripts/llm/vendor-selector.mjs | 124 -- scripts/local-env/setup.sh | 27 + scripts/publish/bump-version.sh | 23 +- scripts/start-feature.sh | 70 +- tools/web/seed-e2e.mjs | 9 +- 622 files changed, 15127 insertions(+), 9701 deletions(-) rename {.llm/exports/.state => .cursor/hooks}/.gitkeep (100%) create mode 100644 .cursor/hooks/CURSOR-HOOKS.md rename {.llm/exports/github-copilot => .cursor/prompts}/.gitkeep (100%) create mode 100644 .cursor/prompts/CURSOR-PROMPTS.md create mode 100644 .cursor/rules/CURSOR-RULES.md create mode 100644 .cursor/rules/abcmemory-vocabulary.mdc create mode 100644 .cursor/rules/app-internal-import-aliases.mdc create mode 100644 .cursor/rules/app-local-ui-wrappers.mdc create mode 100644 .cursor/rules/architecture-tier-dependencies.mdc create mode 100644 .cursor/rules/build-order-doc-sync.mdc create mode 100644 .cursor/rules/config-type-safety.mdc create mode 100644 .cursor/rules/css-custom-properties-no-var-fallbacks.mdc create mode 100644 .cursor/rules/documentation-updates.mdc create mode 100644 .cursor/rules/e2e-seed-id-text-limits.mdc create mode 100644 .cursor/rules/github-actions-yaml.mdc create mode 100644 .cursor/rules/import-specifiers-tiered.mdc create mode 100644 .cursor/rules/infra-k8s.mdc delete mode 100644 .cursor/rules/llm-exports-ci.mdc delete mode 100644 .cursor/rules/llm-history-tracking.mdc create mode 100644 .cursor/rules/openapi-sync.mdc create mode 100644 .cursor/rules/operator-only-git-operations.mdc create mode 100644 .cursor/rules/prefer-shared-ui-web-management.mdc create mode 100644 .cursor/rules/shared-ui-i18n.mdc create mode 100644 .cursor/rules/startup-validation-env-order.mdc create mode 100644 .cursor/rules/test-timeout-budget.mdc create mode 100644 .cursor/skills/CURSOR-SKILLS.md create mode 100644 .cursor/skills/abcmemory/SKILL.md create mode 100644 .cursor/skills/build-order/SKILL.md create mode 100644 .cursor/skills/docker-runtime-workspace-parity/SKILL.md create mode 100644 .cursor/skills/e2e-seed-nano-id-limits/SKILL.md create mode 100644 .cursor/skills/env-defaults-match-code/SKILL.md create mode 100644 .cursor/skills/git-worktree-sibling/SKILL.md create mode 100644 .cursor/skills/github/SKILL.md create mode 100644 .cursor/skills/import-specifiers-tiered/SKILL.md delete mode 100644 .cursor/skills/llm-exports-scripts/SKILL.md delete mode 100644 .cursor/skills/llm-history/SKILL.md create mode 100644 .cursor/skills/logging/SKILL.md create mode 100644 .cursor/skills/management-post-save-navigation/SKILL.md create mode 100644 .cursor/skills/modal-layout-contract/SKILL.md create mode 100644 .cursor/skills/native-deps-platform-mismatch/SKILL.md create mode 100644 .cursor/skills/observability/SKILL.md create mode 100644 .cursor/skills/parallel-plan-execution/SKILL.md create mode 100644 .cursor/skills/plan-completion/SKILL.md create mode 100644 .cursor/skills/rate-limit-message/SKILL.md delete mode 100644 .cursor/skills/release-changelog/SKILL.md create mode 100644 .cursor/skills/routing-url-params/SKILL.md create mode 100644 .cursor/skills/startup-validation-env-order/SKILL.md create mode 100644 .cursor/skills/styles-import-last/SKILL.md create mode 100644 .cursor/skills/styles-source-of-truth/SKILL.md create mode 100644 .cursor/skills/time-format-local/SKILL.md create mode 100644 .cursor/skills/ui-component-promotion/SKILL.md create mode 100644 .cursor/skills/ui-e2e-screenshot-report/SKILL.md create mode 100644 .cursor/skills/unit-test-design-no-overgranularity/SKILL.md create mode 100644 .cursor/skills/unit-test-new-code-gate/SKILL.md create mode 100644 .cursor/skills/unit-test-priority-confident/SKILL.md create mode 100644 .github/workflows/complete-feature.yml delete mode 100644 .github/workflows/llm-exports-full-sync.yml delete mode 100644 .github/workflows/llm-exports-optional-cloud-llm.yml delete mode 100644 .github/workflows/llm-exports-sync.yml create mode 100644 .github/workflows/vulnerability-scanner.yml rename .llm/{exports/opencode => context}/.gitkeep (100%) create mode 100644 .llm/context/LLM-CONTEXT.md create mode 100644 .llm/context/architecture.md create mode 100644 .llm/context/conventions.md delete mode 100644 .llm/exports/LLM-EXPORTS.md delete mode 100644 .llm/exports/github-copilot/copilot-instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/api-no-pii-credentials-in-responses.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/avoid-type-assertions.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/commands-from-metaboost-root.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/e2e-run-with-make-only.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/e2e-suite-title.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/end-with-targeted-make-report-verify.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/env-file-formatting.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/eqeqeq-strict-equality.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/i18n-locale-language.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/llm-cursor-source.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/llm-exports-ci.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/llm-history-tracking.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/markdown-formatting.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/nix-terminal-wrapper.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/path-casing-imports.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/plan-creation.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/plan-execution-completion-tracking.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/semicolons-required.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/single-readme.instructions.md delete mode 100644 .llm/exports/github-copilot/instructions/type-imports-separate-line.instructions.md delete mode 100644 .llm/exports/github-copilot/skills/api-testing/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/api/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/argocd-gitops-push/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/avoid-line-height/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/avoid-unknown-types/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/avoid-unused-props-vars/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/avoid-wrapper-elements/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/button-loading-async/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/catch-unused-error/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/crud-tables-resources/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/database-schema-naming/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/documentation-conventions/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-authz-matrix/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-crud-state-matrix/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-page-tests/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-permission-actor-matrix/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-readability/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-report-order/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-screenshot-verified-element/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/e2e-url-state-contracts/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/feature-implementation-testing/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/focus-outline-visible/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/generate-data-sync/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/global/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/helpers-requests-req-pattern/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/i18n/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/interactive-prompts/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/linear-db-migrations/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/llm-cursor-source/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/llm-exports-scripts/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/llm-history/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/local-docker-k3d-alignment/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/management-edit-breadcrumbs/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/management-edit-page-tabs/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/nested-resource-prefix-naming/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/npm-audit/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/password-strength-on-set-update/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/path-casing-imports/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/plan-files-convention/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/prefer-named-exports/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/release-changelog/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/response-ending-make-verify/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/reusable-components/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/roles-schema-sync/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/single-focus-indicator/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/single-readme/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/sort-prefs-cookie-by-path/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/storybook-component-docs/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/swagger-openapi/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/tab-active-state-url-match/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/table-sort-defaults/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/tables-support-sorting/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/typeorm-orderby-property-names/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/unit-tests-confident-granularity/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/unit-tests-risk-first/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/unit-tests-security-authz-template/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/use-form-component/SKILL.md delete mode 100644 .llm/exports/github-copilot/skills/web/SKILL.md delete mode 100644 .llm/exports/opencode/instructions/api-no-pii-credentials-in-responses.instructions.md delete mode 100644 .llm/exports/opencode/instructions/avoid-type-assertions.instructions.md delete mode 100644 .llm/exports/opencode/instructions/commands-from-metaboost-root.instructions.md delete mode 100644 .llm/exports/opencode/instructions/e2e-run-with-make-only.instructions.md delete mode 100644 .llm/exports/opencode/instructions/e2e-suite-title.instructions.md delete mode 100644 .llm/exports/opencode/instructions/end-with-targeted-make-report-verify.instructions.md delete mode 100644 .llm/exports/opencode/instructions/env-file-formatting.instructions.md delete mode 100644 .llm/exports/opencode/instructions/eqeqeq-strict-equality.instructions.md delete mode 100644 .llm/exports/opencode/instructions/i18n-locale-language.instructions.md delete mode 100644 .llm/exports/opencode/instructions/llm-cursor-source.instructions.md delete mode 100644 .llm/exports/opencode/instructions/llm-exports-ci.instructions.md delete mode 100644 .llm/exports/opencode/instructions/llm-history-tracking.instructions.md delete mode 100644 .llm/exports/opencode/instructions/markdown-formatting.instructions.md delete mode 100644 .llm/exports/opencode/instructions/nix-terminal-wrapper.instructions.md delete mode 100644 .llm/exports/opencode/instructions/path-casing-imports.instructions.md delete mode 100644 .llm/exports/opencode/instructions/plan-creation.instructions.md delete mode 100644 .llm/exports/opencode/instructions/plan-execution-completion-tracking.instructions.md delete mode 100644 .llm/exports/opencode/instructions/semicolons-required.instructions.md delete mode 100644 .llm/exports/opencode/instructions/single-readme.instructions.md delete mode 100644 .llm/exports/opencode/instructions/type-imports-separate-line.instructions.md delete mode 100644 .llm/exports/opencode/opencode-instructions.md delete mode 100644 .llm/exports/opencode/skills/api-testing/SKILL.md delete mode 100644 .llm/exports/opencode/skills/api/SKILL.md delete mode 100644 .llm/exports/opencode/skills/argocd-gitops-push/SKILL.md delete mode 100644 .llm/exports/opencode/skills/avoid-line-height/SKILL.md delete mode 100644 .llm/exports/opencode/skills/avoid-unknown-types/SKILL.md delete mode 100644 .llm/exports/opencode/skills/avoid-unused-props-vars/SKILL.md delete mode 100644 .llm/exports/opencode/skills/avoid-wrapper-elements/SKILL.md delete mode 100644 .llm/exports/opencode/skills/button-loading-async/SKILL.md delete mode 100644 .llm/exports/opencode/skills/catch-unused-error/SKILL.md delete mode 100644 .llm/exports/opencode/skills/crud-tables-resources/SKILL.md delete mode 100644 .llm/exports/opencode/skills/database-schema-naming/SKILL.md delete mode 100644 .llm/exports/opencode/skills/documentation-conventions/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-authz-matrix/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-crud-state-matrix/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-page-tests/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-permission-actor-matrix/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-readability/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-report-order/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-screenshot-verified-element/SKILL.md delete mode 100644 .llm/exports/opencode/skills/e2e-url-state-contracts/SKILL.md delete mode 100644 .llm/exports/opencode/skills/feature-implementation-testing/SKILL.md delete mode 100644 .llm/exports/opencode/skills/focus-outline-visible/SKILL.md delete mode 100644 .llm/exports/opencode/skills/generate-data-sync/SKILL.md delete mode 100644 .llm/exports/opencode/skills/global/SKILL.md delete mode 100644 .llm/exports/opencode/skills/helpers-requests-req-pattern/SKILL.md delete mode 100644 .llm/exports/opencode/skills/i18n/SKILL.md delete mode 100644 .llm/exports/opencode/skills/interactive-prompts/SKILL.md delete mode 100644 .llm/exports/opencode/skills/linear-db-migrations/SKILL.md delete mode 100644 .llm/exports/opencode/skills/llm-cursor-source/SKILL.md delete mode 100644 .llm/exports/opencode/skills/llm-exports-scripts/SKILL.md delete mode 100644 .llm/exports/opencode/skills/llm-history/SKILL.md delete mode 100644 .llm/exports/opencode/skills/local-docker-k3d-alignment/SKILL.md delete mode 100644 .llm/exports/opencode/skills/management-edit-breadcrumbs/SKILL.md delete mode 100644 .llm/exports/opencode/skills/management-edit-page-tabs/SKILL.md delete mode 100644 .llm/exports/opencode/skills/nested-resource-prefix-naming/SKILL.md delete mode 100644 .llm/exports/opencode/skills/npm-audit/SKILL.md delete mode 100644 .llm/exports/opencode/skills/password-strength-on-set-update/SKILL.md delete mode 100644 .llm/exports/opencode/skills/path-casing-imports/SKILL.md delete mode 100644 .llm/exports/opencode/skills/plan-files-convention/SKILL.md delete mode 100644 .llm/exports/opencode/skills/prefer-named-exports/SKILL.md delete mode 100644 .llm/exports/opencode/skills/release-changelog/SKILL.md delete mode 100644 .llm/exports/opencode/skills/response-ending-make-verify/SKILL.md delete mode 100644 .llm/exports/opencode/skills/reusable-components/SKILL.md delete mode 100644 .llm/exports/opencode/skills/roles-schema-sync/SKILL.md delete mode 100644 .llm/exports/opencode/skills/single-focus-indicator/SKILL.md delete mode 100644 .llm/exports/opencode/skills/single-readme/SKILL.md delete mode 100644 .llm/exports/opencode/skills/sort-prefs-cookie-by-path/SKILL.md delete mode 100644 .llm/exports/opencode/skills/storybook-component-docs/SKILL.md delete mode 100644 .llm/exports/opencode/skills/swagger-openapi/SKILL.md delete mode 100644 .llm/exports/opencode/skills/tab-active-state-url-match/SKILL.md delete mode 100644 .llm/exports/opencode/skills/table-sort-defaults/SKILL.md delete mode 100644 .llm/exports/opencode/skills/tables-support-sorting/SKILL.md delete mode 100644 .llm/exports/opencode/skills/typeorm-orderby-property-names/SKILL.md delete mode 100644 .llm/exports/opencode/skills/unit-tests-confident-granularity/SKILL.md delete mode 100644 .llm/exports/opencode/skills/unit-tests-risk-first/SKILL.md delete mode 100644 .llm/exports/opencode/skills/unit-tests-security-authz-template/SKILL.md delete mode 100644 .llm/exports/opencode/skills/use-form-component/SKILL.md delete mode 100644 .llm/exports/opencode/skills/web/SKILL.md create mode 100644 .llm/history/active/LLM-HISTORY-ACTIVE.md create mode 100644 .llm/history/active/cross-app-error-boundaries/cross-app-error-boundaries-part-01.md create mode 100644 .llm/history/active/css-var-no-fallbacks/css-var-no-fallbacks-part-01.md create mode 100644 .llm/history/active/fix-ebadplatform-npm-ci/fix-ebadplatform-npm-ci-part-01.md create mode 100644 .llm/history/active/metaboost-bucket-webpush-foundation/metaboost-bucket-webpush-foundation-part-01.md create mode 100644 .llm/history/active/metaboost-staged-changes-cleanup/metaboost-staged-changes-cleanup-part-01.md create mode 100644 .llm/history/active/product-membership-metaboost-common/product-membership-metaboost-common-part-01.md create mode 100644 .llm/history/completed/LLM-HISTORY-COMPLETED.md create mode 100644 .llm/plans/active/LLM-PLANS-ACTIVE.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/00-SUMMARY.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/01-contract-parity.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/02-metaboost-runtime-and-theme-system.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/03-metaboost-e2e-matrix.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/04-abcmemory-and-doc-sync.md create mode 100644 .llm/plans/active/custom-css-remote-file-operator-themes/COPY-PASTA.md create mode 100644 .llm/plans/completed/LLM-PLANS-COMPLETED.md create mode 100644 .llm/plans/completed/doc-link-path-missed-followup/00-SUMMARY.md create mode 100644 .llm/plans/completed/doc-link-path-missed-followup/02-metaboost-docs-wrong-paths.md create mode 100644 .llm/plans/completed/doc-link-path-missed-followup/03-metaboost-completed-plans-stale.md create mode 100644 .llm/plans/completed/doc-link-path-missed-followup/04-verify-and-script-aliases.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/01-adopt-podverse-process-rules.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/02-port-drifted-shared-rules.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/03-port-drifted-shared-skills.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/04-align-cursorrules.md create mode 100644 .llm/plans/completed/metaboost-abcmemory-align/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/01-backend-domain-and-schema.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/02-api-and-webpush-channel.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/03-web-ui-and-service-worker.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/04-env-vapid-and-local-setup.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/05-tests-and-verification.md create mode 100644 .llm/plans/completed/metaboost-bucket-webpush-foundation/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-build-config-align/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-build-config-align/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-build-config-align/01-fix-eslint-stale-globs-and-commit-rules.md create mode 100644 .llm/plans/completed/metaboost-build-config-align/02-complete-vitest-package-wiring.md create mode 100644 .llm/plans/completed/metaboost-build-config-align/03-remove-lint-staged-from-package-json.md create mode 100644 .llm/plans/completed/metaboost-build-config-align/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/01-adopt-podverse-ci-workflows.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/02-align-ci-yml-gates.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/03-align-publish-and-dependabot.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/04-align-repo-management-docs.md create mode 100644 .llm/plans/completed/metaboost-ci-ops-align/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-dev-scripts-align/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-dev-scripts-align/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-dev-scripts-align/01-align-git-hooks-with-podverse.md create mode 100644 .llm/plans/completed/metaboost-dev-scripts-align/02-sync-parity-dev-tooling-files.md create mode 100644 .llm/plans/completed/metaboost-dev-scripts-align/03-review-bump-version-audit-gate.md create mode 100644 .llm/plans/completed/metaboost-dev-scripts-align/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-infra-env-align/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-infra-env-align/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-infra-env-align/01-sync-k8s-env-sources-with-templates.md create mode 100644 .llm/plans/completed/metaboost-infra-env-align/02-regenerate-linear-baseline-gz.md create mode 100644 .llm/plans/completed/metaboost-infra-env-align/03-verify-alpha-product-membership-wiring.md create mode 100644 .llm/plans/completed/metaboost-infra-env-align/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-llm-exports-reference-cleanup/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-llm-exports-reference-cleanup/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-llm-exports-reference-cleanup/01-remove-stale-eslint-llm-glob.md create mode 100644 .llm/plans/completed/metaboost-llm-exports-reference-cleanup/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/01-align-llm-root-and-dev-docs.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/02-align-llm-index-and-context.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/03-cleanup-llm-local.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/04-complete-feature-workflow-gap.md create mode 100644 .llm/plans/completed/metaboost-llm-workspace-align/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/01-llm-exports-removal-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/02-changelog-removal-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/03-cursor-abcmemory-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/04-llm-workspace-docs-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/05-ci-github-workflows-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/06-k8s-alpha-infra-env-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/07-git-hooks-dev-scripts-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/08-build-package-config-triage.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/09-podverse-parity-inventory.md create mode 100644 .llm/plans/completed/metaboost-podverse-alignment-triage/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/01-adopt-podverse-testing-standards.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/02-adopt-podverse-build-env-ops-skills.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/03-adopt-podverse-engineering-ops-skills.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/04-adopt-podverse-ui-shared-standards.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/05-adopt-run-workspaces-ci-script.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/COPY-PASTA.md create mode 100644 .llm/plans/completed/metaboost-podverse-parity-gaps/PROMOTE-TO-PODVERSE-NOTES.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/00-SUMMARY.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/01-error-boundaries-shared-ui.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/02-notification-dispatch-perf.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/03-shared-api-contract-types.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/04-schema-and-error-helpers.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/05-helpers-and-bell-refactor.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/06-style-and-import-hygiene.md create mode 100644 .llm/plans/completed/metaboost-staged-changes-cleanup/COPY-PASTA.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/00-SUMMARY.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/01-remove-scripts-and-ci.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/02-remove-exports-tree-and-gitignore.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/03-rewrite-cursor-guidance.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/04-revise-llm-docs.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/06-remote-and-operator-cleanup.md create mode 100644 .llm/plans/completed/remove-multi-llm-exports/COPY-PASTA.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/00-EXECUTION-ORDER.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/00-SUMMARY.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/01-tier-boundaries-and-scope.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/02-verification-spike.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/03-eslint-enforcement.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/04-monorepo-sweep-matrix.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/05-documentation-skills-rules.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/06-future-convergence-todo.md create mode 100644 .llm/plans/completed/tiered-import-specifiers-node-vs-next/COPY-PASTA.md create mode 100644 .llm/templates/LLM-TEMPLATES.md create mode 100644 apps/api/src/controllers/webPushSubscriptionsController.ts create mode 100644 apps/api/src/lib/logger.ts create mode 100644 apps/api/src/lib/notifications/notifyNewBucketMessage.ts create mode 100644 apps/api/src/lib/notifications/webPushChannel.ts create mode 100644 apps/api/src/middleware/auth.ts create mode 100644 apps/api/src/schemas/webPushSubscriptions.ts create mode 100644 apps/api/src/test/notification-webpush.test.ts create mode 100644 apps/api/src/test/require-user.test.ts create mode 100644 apps/management-web/src/app/error.tsx create mode 100644 apps/management-web/src/app/global-error.tsx create mode 100644 apps/web/e2e/bucket-notifications-webpush-bucket-owner.spec.ts create mode 100644 apps/web/public/webpush-sw.js create mode 100644 apps/web/src/app/(main)/bucket/[id]/BucketName.module.scss create mode 100644 apps/web/src/app/(main)/bucket/[id]/BucketNotificationsBell.tsx create mode 100644 apps/web/src/app/(main)/bucket/[id]/useBucketWebPushSubscription.ts create mode 100644 apps/web/src/app/error.tsx create mode 100644 apps/web/src/app/global-error.tsx create mode 100644 apps/web/src/components/useApplyToDescendantsModal.tsx create mode 100644 apps/web/src/lib/webPushBrowser.ts delete mode 100644 docs/development/CHANGELOGS/0.1.10.md delete mode 100644 docs/development/CHANGELOGS/0.1.11.md delete mode 100644 docs/development/CHANGELOGS/0.1.12.md delete mode 100644 docs/development/CHANGELOGS/0.1.13.md delete mode 100644 docs/development/CHANGELOGS/0.1.14.md delete mode 100644 docs/development/CHANGELOGS/0.1.15.md create mode 100644 docs/development/env/WEB-PUSH-LOCAL.md delete mode 100644 docs/development/llm/CURSOR-COPILOT-SYNC.md delete mode 100644 docs/development/llm/EXPORT-TARGETS.md delete mode 100644 docs/development/llm/GH-EXPORTS-SETUP.md delete mode 100644 docs/development/llm/LLM-EDITOR-ALIGNMENT-PROMPT.md create mode 100644 docs/development/llm/LLM-HISTORY-WORKFLOW-ARCHIVE.md create mode 100644 docs/development/tooling/DOCS-DEVELOPMENT-TOOLING-IMPORT-SPECIFIERS.md delete mode 100644 docs/operations/CHANGELOG-ARCHIVE/DOCS-OPERATIONS-CHANGELOG-ARCHIVE.md delete mode 100644 docs/operations/CHANGELOG-UPCOMING.md create mode 100644 eslint-rules/require-relative-js-extension.mjs create mode 100644 packages/helpers-requests/src/request.cookieHeaderToHeaders.test.ts create mode 100644 packages/helpers-requests/vitest.config.ts delete mode 100644 packages/metaboost-signing/CHANGELOG.md create mode 100644 packages/orm/src/entities/BucketNotificationPreference.ts create mode 100644 packages/orm/src/entities/UserWebPushSubscription.ts create mode 100644 packages/orm/src/lib/pgError.test.ts create mode 100644 packages/orm/src/lib/pgError.ts create mode 100644 packages/orm/src/services/BucketNotificationPreferenceService.ts create mode 100644 packages/orm/src/services/UserWebPushSubscriptionService.ts create mode 100644 packages/orm/vitest.config.ts create mode 100644 packages/ui/src/components/feedback/AppErrorBoundary/AppErrorBoundary.module.scss create mode 100644 packages/ui/src/components/feedback/AppErrorBoundary/AppErrorBoundary.stories.tsx create mode 100644 packages/ui/src/components/feedback/AppErrorBoundary/AppErrorBoundary.test.tsx create mode 100644 packages/ui/src/components/feedback/AppErrorBoundary/AppErrorBoundary.tsx create mode 100644 packages/ui/src/components/feedback/GlobalErrorBoundary/GlobalErrorBoundary.module.scss create mode 100644 packages/ui/src/components/feedback/GlobalErrorBoundary/GlobalErrorBoundary.stories.tsx create mode 100644 packages/ui/src/components/feedback/GlobalErrorBoundary/GlobalErrorBoundary.tsx create mode 100644 packages/ui/src/lib/errorBoundaryStrings.ts create mode 100644 packages/ui/vitest.config.ts create mode 100644 scripts/ci/run-workspaces.mjs create mode 100755 scripts/development/generate-webpush-vapid-keys.sh create mode 100644 scripts/development/normalize-markdown-links.mjs mode change 100644 => 100755 scripts/git-hooks/install-hooks.sh delete mode 100644 scripts/git-hooks/pre-commit mode change 100644 => 100755 scripts/git-hooks/pre-push delete mode 100644 scripts/llm/allowed-targets.mjs delete mode 100644 scripts/llm/export-from-cursor.mjs delete mode 100644 scripts/llm/guard-exports-prompt.sh delete mode 100644 scripts/llm/lib/copilot-adapter.mjs delete mode 100644 scripts/llm/lib/github-style-adapter.mjs delete mode 100644 scripts/llm/lib/opencode-adapter.mjs delete mode 100644 scripts/llm/vendor-config.mjs delete mode 100644 scripts/llm/vendor-selector.mjs diff --git a/.llm/exports/.state/.gitkeep b/.cursor/hooks/.gitkeep similarity index 100% rename from .llm/exports/.state/.gitkeep rename to .cursor/hooks/.gitkeep diff --git a/.cursor/hooks/CURSOR-HOOKS.md b/.cursor/hooks/CURSOR-HOOKS.md new file mode 100644 index 00000000..e5306926 --- /dev/null +++ b/.cursor/hooks/CURSOR-HOOKS.md @@ -0,0 +1,7 @@ +# `.cursor/hooks/` + +Optional Cursor project hook scripts. Add `.cursor/hooks.json` at the repo root when you enable hooks. + +- Scripts in this directory are referenced from `hooks.json` (not committed until you need hooks). +- Prefer skills and rules for static guidance; hooks automate events (shell gates, session setup, and so on). +- See Cursor hooks documentation and **abcmemory** skill. diff --git a/.llm/exports/github-copilot/.gitkeep b/.cursor/prompts/.gitkeep similarity index 100% rename from .llm/exports/github-copilot/.gitkeep rename to .cursor/prompts/.gitkeep diff --git a/.cursor/prompts/CURSOR-PROMPTS.md b/.cursor/prompts/CURSOR-PROMPTS.md new file mode 100644 index 00000000..b19e9e3b --- /dev/null +++ b/.cursor/prompts/CURSOR-PROMPTS.md @@ -0,0 +1,8 @@ +# `.cursor/prompts/` + +Reusable agent-facing prompt snippets (abcmemory). One topic per kebab-case `.md` file. + +- Use for copy-paste or @-reference blocks that are not full skills or rules. +- Do not put human plan/history templates here — use `.llm/templates/` instead. +- When the user says **abcremember** with prompt-shaped guidance, prefer this directory. +- See **abcmemory** skill and [LLM-TEMPLATES.md](/.llm/templates/LLM-TEMPLATES.md). diff --git a/.cursor/rules/CURSOR-RULES.md b/.cursor/rules/CURSOR-RULES.md new file mode 100644 index 00000000..94922355 --- /dev/null +++ b/.cursor/rules/CURSOR-RULES.md @@ -0,0 +1,7 @@ +# `.cursor/rules/` + +Cursor rules (`.mdc` files): always-applied or glob-scoped agent constraints. + +- Use `alwaysApply: true` for repo-wide conventions (for example `architecture-tier-dependencies.mdc`); use `globs` when rules apply only to certain paths. +- Prefer skills for long workflow docs; keep rules focused. +- See **llm-cursor-source** and [DOCS-DEVELOPMENT-LLM.md](/docs/development/llm/DOCS-DEVELOPMENT-LLM.md). diff --git a/.cursor/rules/abcmemory-vocabulary.mdc b/.cursor/rules/abcmemory-vocabulary.mdc new file mode 100644 index 00000000..629fd844 --- /dev/null +++ b/.cursor/rules/abcmemory-vocabulary.mdc @@ -0,0 +1,29 @@ +--- +description: User vocabulary abcmemory and abcremember for Cursor guidance files +alwaysApply: true +--- + +# abcmemory / abcremember vocabulary + +## abcmemory + +**abcmemory** means the committed files Cursor uses for agent guidance in this repo — **only** under `.cursor/` and the repo root files Cursor reads: + +- `.cursor/skills/**` +- `.cursor/rules/**` +- `.cursor/prompts/**` +- `.cursor/hooks/**` and `.cursor/hooks.json` (when present) +- `.cursorrules` +- `.cursorignore` + +## Not abcmemory + +**`.llm/`** (plans, history, templates, context) is a **planning workspace**, not agent memory. Do not treat it as a second abcmemory tree. + +Also not abcmemory unless you explicitly ask: `docs/`, `.github/`, and ad-hoc paths. + +## abcremember + +When the user says **abcremember**, **create or update** the best-fit abcmemory file(s) under **`.cursor/`** (or `.cursorrules` / `.cursorignore`): prefer updating existing guidance when the instruction extends the same topic; create a new skill, rule, or prompt only when no suitable file exists. Then follow that guidance in future work. Do **not** write to `.llm/` unless they explicitly say to remember something there. + +For placement and workflow details, see `.cursor/skills/abcmemory/SKILL.md`. diff --git a/.cursor/rules/app-internal-import-aliases.mdc b/.cursor/rules/app-internal-import-aliases.mdc new file mode 100644 index 00000000..749f75c7 --- /dev/null +++ b/.cursor/rules/app-internal-import-aliases.mdc @@ -0,0 +1,38 @@ +--- +description: App-internal TypeScript path aliases must match the full apps// directory name +globs: + - "apps/api/**/*.ts" + - "apps/management-api/**/*.ts" + - "apps/api/tsconfig*.json" + - "apps/management-api/tsconfig*.json" + - "apps/api/vitest.config.ts" + - "apps/management-api/vitest.config.ts" +alwaysApply: false +--- + +# App internal import aliases + +Node apps under `apps/` use a **TypeScript path alias** for imports from their own `src/` tree. The alias prefix must match the **full app directory name** under `apps/` — not abbreviations. + +## Do + +| App directory | Alias prefix | Example | +| ----------------------- | --------------------- | ------------------------------------------------------------ | +| `apps/api/` | `@api/*` | `import { config } from '@api/config/index.js'` | +| `apps/management-api/` | `@management-api/*` | `import { config } from '@management-api/config/index.js'` | + +Configure in each app's `tsconfig.json` `paths` and mirror the prefix root in `vitest.config.ts` for tests. When the build uses alias rewriting, keep `tsc` + alias rewrite aligned with dist output. + +## Don't + +- Do not abbreviate directory names (`@mgmt-api`, `@m-api`, etc.). +- Do not use npm workspace names (`@metaboost/api/*`) for in-app imports — those are for cross-package deps only. + +## Distinction + +- **`@metaboost/*`** — published/workspace packages under `packages/` +- **`@/*`** — internal imports within one deployable app + +## Note + +When adding or extending `paths` in an app, update this table if a new Node app is introduced under `apps/`. diff --git a/.cursor/rules/app-local-ui-wrappers.mdc b/.cursor/rules/app-local-ui-wrappers.mdc new file mode 100644 index 00000000..09bd762a --- /dev/null +++ b/.cursor/rules/app-local-ui-wrappers.mdc @@ -0,0 +1,16 @@ +--- +description: Extract app-local thin wrappers when identical @metaboost/ui + i18n configuration repeats (2+ callsites in one app). +globs: + - "apps/web/src/**/*.tsx" + - "apps/management-web/src/**/*.tsx" +alwaysApply: false +--- + +# App-local configured UI wrappers + +When the **same** configured usage of `@metaboost/ui` (including **`next-intl`** strings for `aria-label`, titles, or messages) appears in **two or more** places **within** `apps/web` or **within** `apps/management-web`, extract a thin client component under that app’s `src/components/**` that wires translations and forwards props. + +- Keep wrappers **app-local** until **both** apps need the same wiring; then consider `packages/ui` plus app wrappers for copy (see workspace **i18n** rules). +- Do **not** use bare `export { X } from '@metaboost/ui'` as a substitute — wrappers must bind meaningful app conventions. + +See **`reusable-components`** skill for the full rubric. diff --git a/.cursor/rules/architecture-tier-dependencies.mdc b/.cursor/rules/architecture-tier-dependencies.mdc new file mode 100644 index 00000000..4bea29bd --- /dev/null +++ b/.cursor/rules/architecture-tier-dependencies.mdc @@ -0,0 +1,11 @@ +--- +description: Package tier dependency order — lower tiers cannot depend on higher +alwaysApply: true +--- + +# Architecture tier dependencies + +Lower tiers cannot depend on higher tiers in the monorepo package graph. + +Full tier table, directory layout, and app descriptions: +[.llm/context/architecture.md](/.llm/context/architecture.md) (contributor reference). diff --git a/.cursor/rules/build-order-doc-sync.mdc b/.cursor/rules/build-order-doc-sync.mdc new file mode 100644 index 00000000..2b3f7cbf --- /dev/null +++ b/.cursor/rules/build-order-doc-sync.mdc @@ -0,0 +1,24 @@ +--- +description: Keep build order dependency-safe and docs synced when build orchestration changes. +globs: + - package.json + - scripts/ci/run-workspaces.mjs + - .github/workflows/ci.yml +alwaysApply: false +--- + +# Build order and docs sync + +Use the **build-order** skill (`.cursor/skills/build-order/SKILL.md`) and +[AGENTS.md](/AGENTS.md) / [.llm/context/architecture.md](/.llm/context/architecture.md) for the +current Metaboost sequence. + +## Required + +- Preserve dependency-safe staged build execution (`npm run build:packages` before apps). +- Keep root `build` aligned with package/app build sequencing. +- Update contributor docs when build orchestration behavior changes. + +## Prohibited + +- Do not reintroduce all-workspace root build execution that can violate workspace dependency order. diff --git a/.cursor/rules/commands-from-metaboost-root.mdc b/.cursor/rules/commands-from-metaboost-root.mdc index 1031c827..d49bc28f 100644 --- a/.cursor/rules/commands-from-metaboost-root.mdc +++ b/.cursor/rules/commands-from-metaboost-root.mdc @@ -10,6 +10,7 @@ Always provide runnable terminal commands relative to the Metaboost repository r ## Do - Use root-invoked workspace commands (for example `npm run