Enterprise foundation — org CRUD, billing modes, SSO, dashboard#655
Enterprise foundation — org CRUD, billing modes, SSO, dashboard#655teetangh wants to merge 239 commits into
Conversation
…IDER feature-flagged) Adds the enterprise/organization profile layer on top of BetterAuth's existing Organization/Member/Invitation tables. The split: - BetterAuth tables (identity): name, slug, role-as-string, invitations - OrganizationProfile (this PR): kind, billing mode, branding, rates, seats - OrganizationMemberProfile (this PR): typed enum role + status + profile FKs PROVIDER (consultant agency) support is in the schema but gated by the ENABLE_PROVIDER_ORGS env flag at the API/UI layer (lib/feature-flags.ts in the next commit). The PROVIDER-specific tables are marked with FEATURE-FLAGGED comments — they exist on disk but are inert until the flag is flipped. See Issue #646. New models: - OrganizationProfile (1:1 with Organization) — kind, status, billingMode, billing email, branding, revenue rates (gated), seat budget, payment terms, policies - OrganizationMemberProfile (1:1 with Member) — typed role enum, status, optional consultantProfileId/consulteeProfileId FKs - OrganizationSSOSettings — allowedEmailDomains, enforceSSO, defaultRoleForAutoJoin - OrganizationInvoice — used by both INVOICED_MONTHLY (BUYER) and PROVIDER (gated). Includes GST compliance fields, billing cycle metadata, autoGenerated flag for cron-vs-manual. - OrganizationPlan — org-curated catalog plan templates - OrgCreditPool / OrgCreditPurchase / OrgCreditLedger — SEAT_PACK billing pool + immutable credit ledger - OrganizationPayoutAccount / OrganizationPayout / OrganizationEarnings — FEATURE-FLAGGED PROVIDER-only tables, schema-only New enums: - OrganizationKind (BUYER | PROVIDER | HYBRID) - OrganizationStatus (PENDING_VERIFICATION | ACTIVE | SUSPENDED | DEACTIVATED) - OrganizationBillingMode (TAG_ONLY | SEAT_PACK | INVOICED_MONTHLY) - OrgSizeBucket (4 size buckets) - OrgMemberRole (6 values; ORG_CONSULTANT and ORG_SUPPORT FEATURE-FLAGGED) - OrgMemberStatus - OrgPayoutAccountStatus - OrgInvoiceStatus Additive links to existing models (additive only, no breaking changes): - Member.organizationMemberProfile (back-relation) - ConsultantProfile.organizationMemberProfile + isIndependent flag - ConsulteeProfile.organizationMemberProfile + isIndependent flag - ConsultationPlan/SubscriptionPlan/WebinarPlan/ClassPlan.organizationProfileId - Payment.organizationProfileId + Payment.billableToOrgInvoiceId - Payment back-relations: organizationEarnings (PROVIDER, gated), organizationInvoiceSettled, orgCreditPurchase Verified: - npx prisma format passes - npx prisma validate passes - npx prisma generate produces types for all new models Schema migration is purely additive — no existing columns dropped. Supabase migration deferred to Phase O at the end of PR2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…plugins
Phase B of PR2. Wires up the BetterAuth Organization + SSO plugins on
top of the schema added in Phase A.
Why upgrade better-auth from 1.4.18 → 1.6.2:
- @better-auth/sso requires per-version peer dep matching:
@better-auth/sso@1.4.18 has packaging issues (imports `better-call`
at runtime without declaring it as a direct dep, so Node ESM
resolution can't find it when nested under better-auth/node_modules).
- @better-auth/sso@1.6.2 works cleanly but requires better-auth@^1.6.2
and @better-auth/core@^1.6.2 (peer dep mismatch with 1.4.18).
- The 1.4 → 1.6 upgrade is API-compatible for our customSession +
organization usage. Verified by re-running `npx @better-auth/cli generate`
after the upgrade — schema generation succeeds, plugin loading
succeeds, no breaking changes to the customSession callback shape.
Dependencies added to package.json:
- better-auth: ^1.4.18 → ^1.6.2
- @better-auth/sso: ^1.6.2 (new)
- @opentelemetry/api: ^1.9.0 (new — required by @better-auth/core@1.6.2
runtime instrumentation; declared as peer dep, not pulled in by `npm ci`)
Note: install requires --legacy-peer-deps because better-call@1.3.5
declares an OPTIONAL peer dep on zod@^4 that npm 11 enforces strictly.
The project uses zod@^3.25 (constrained by eslint-plugin-react-hooks
and zod-validation-error). The optional peer is benign at runtime —
better-call works fine with zod 3 — but the install fails without
--legacy-peer-deps. The lockfile is updated accordingly.
Schema changes (auto-applied by `npx @better-auth/cli generate -y`):
- New `SsoProvider` model — BetterAuth's auto-generated table for
per-org SAML/OIDC providers. Has `organizationId` so providers can
be linked to orgs (this is exactly what the SSO admin UI in Phase L
will register against).
- `User.ssoproviders SsoProvider[]` back-relation
- `User.invitations Invitation[]` back-relation (BetterAuth's
organization plugin now expects this; Prisma format auto-added a
matching `user`/`userId` field on the Invitation model — additive,
nullable, no migration breakage)
- `Session.activeOrganizationId String?` — BetterAuth tracks the
user's currently-active org context in the session
- New indexes on `Member.organizationId`, `Member.userId`,
`Invitation.organizationId`, `Invitation.email`, `Verification.identifier`
- Cosmetic: `@db.Timestamptz()` → `@db.Timestamptz` (parens optional
in Prisma 7 — purely formatting, no migration impact)
lib/auth.ts changes:
- Import `organization` from "better-auth/plugins"
- Import `sso` from "@better-auth/sso"
- Enable both plugins:
organization({ organizationLimit: 5, creatorRole: "ORG_OWNER" })
sso()
- Extend customSession callback to load `organizationMemberships`
via prisma.organizationMemberProfile.findMany — joined to the
typed sibling profile + parent OrganizationProfile/Organization,
filtered to status=ACTIVE on both layers. The session now exposes
user.organizationMemberships[] for the OrgSwitcher (Phase G) and
any org-aware route to read without an extra DB roundtrip.
Verified:
- npx prisma validate passes
- npx prisma generate produces types for new models
- npx @better-auth/cli generate succeeds (loads auth.ts + writes schema)
- npm ci --legacy-peer-deps succeeds with the new lockfile
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase C + Phase D of PR2 enterprise foundation. - lib/feature-flags.ts: ENABLE_PROVIDER_ORGS env flag gating PROVIDER org code paths (org creation, member role assignment, earnings split, payouts/consultants routes). Defaults to false pre-MVP. See Issue #646 for the PROVIDER follow-up checklist. - lib/auth-helpers.ts: add requireOrgAccess(orgId, minRole?) and requireOrgOwner(orgId) helpers plus orgRoleSatisfies comparator backed by a numeric ORG_ROLE_RANK table. Platform ADMINs bypass org membership with a synthesized OWNER stub for operability; STAFF do NOT bypass — they are platform-side, not org-side. - middleware.ts: add /api/organizations/ to AUTHENTICATED_API_PREFIXES so routes require a session cookie before reaching the handler (handler-level requireOrgAccess still runs for the typed role check).
Phase E of PR2 enterprise foundation. Adds the full app/api/organizations surface that the dashboard pages (Phase F) and the public invite flow (Phase H) call into. 25 route files in total. Core CRUD - POST/GET /api/organizations: list and create with PROVIDER feature gate - GET/PATCH/DELETE /api/organizations/[orgId]: resource ops; soft delete via status=DEACTIVATED; PROVIDER rate-sum validation gated - GET/PATCH /api/organizations/[orgId]/settings: pass-through alias Members + invitations - GET/POST /api/organizations/[orgId]/members - PATCH/DELETE /api/organizations/[orgId]/members/[memberId] with last-owner guard and seatsUsed bookkeeping for ORG_LEARNER - GET/POST /api/organizations/[orgId]/invitations + DELETE single - POST /api/organizations/invitations/accept (token + email match required) - ORG_CONSULTANT/ORG_SUPPORT roles return 501 unless ENABLE_PROVIDER_ORGS Plans (org-owned catalog) - GET/POST /api/organizations/[orgId]/plans - GET/PATCH/DELETE /api/organizations/[orgId]/plans/[planId] - assignedConsultantIds[] non-empty assignment is PROVIDER-only Billing (BUYER) - GET /api/organizations/[orgId]/billing: stats summary - GET/POST /api/organizations/[orgId]/billing/invoices - POST /api/organizations/[orgId]/billing/generate-invoice (manual rollup for INVOICED_MONTHLY; full Inngest cron lands in Phase K) - POST /api/organizations/[orgId]/billing/invoices/[invoiceId]/pay (returns pendingPhaseK stub until gateway plumbing lands) Credits (SEAT_PACK) - GET /api/organizations/[orgId]/credits (pool + ledger) - GET /api/organizations/[orgId]/credits/purchases - POST /api/organizations/[orgId]/credits/purchase (returns pendingPhaseJ stub until gateway plumbing lands) SSO - GET/PATCH /api/organizations/[orgId]/sso (settings + linked providers) - GET/POST /api/organizations/[orgId]/sso/providers - DELETE /api/organizations/[orgId]/sso/providers/[providerId] PROVIDER stubs (501 unless ENABLE_PROVIDER_ORGS) - /api/organizations/[orgId]/payouts (GET/POST) - /api/organizations/[orgId]/payout-account (GET/PUT/DELETE) - /api/organizations/[orgId]/consultants (GET) Misc - /api/organizations/[orgId]/learners (BUYER + HYBRID) - /api/organizations/[orgId]/analytics (stat-card aggregates) All routes use the new requireOrgAccess / requireOrgOwner helpers for the typed-role auth check; platform admins bypass with the synthesized OWNER stub from Phase D. PROVIDER-gated routes return 501 with the flag name in the body so the dashboard can render an "upgrade required" CTA later. Also fixes the planner Event types to include the new ClassPlan/WebinarPlan.organizationProfileId nullable field added in Phase A.
Phase F of PR2 enterprise foundation. Adds the full app/dashboard/organization/* surface that the org CRUD API routes from Phase E feed into. 15 page files in total (1 landing + 1 layout + 1 redirect + 12 inner pages). Layout - /dashboard/organization/[orgId]/layout.tsx — useSession() gate, fetches the org via /api/organizations/[orgId], renders CollapsibleSidebar with conditional nav items based on org.kind (consultants/payouts hide for BUYER) and billingMode (credits hides unless SEAT_PACK) Landing - /dashboard/organization/page.tsx — lists user's orgs, "New organization" form (BUYER kind only — PROVIDER hidden by feature flag at API layer) Pages - /[orgId]/page.tsx — server redirect to /home - /[orgId]/home/page.tsx — stat-card overview (members, learners, plans, this-month revenue) plus billing-mode-aware secondary cards - /[orgId]/members/page.tsx — table + add-member dialog (existing users) - /[orgId]/invitations/page.tsx — pending invitations table + invite dialog + copy-link button (no email user required) - /[orgId]/learners/page.tsx — ORG_LEARNER list - /[orgId]/consultants/page.tsx — gated; renders feature-locked card when /api/organizations/[orgId]/consultants returns 501 - /[orgId]/plans/page.tsx — org-owned catalog plans CRUD with create dialog - /[orgId]/billing/page.tsx — billing summary stat cards + invoices table; "Generate invoice" button for INVOICED_MONTHLY orgs; "Pay" buttons stub-tolerate the pendingPhaseK marker - /[orgId]/credits/page.tsx — SEAT_PACK credit pool + ledger; "Buy credits" dialog; renders "wrong mode" panel for non-SEAT_PACK orgs - /[orgId]/payouts/page.tsx — gated PROVIDER feature; renders feature-locked card when API returns 501 - /[orgId]/analytics/page.tsx — six-card stat grid - /[orgId]/settings/page.tsx — profile + billing email + payment terms + seat budget form; link to SSO settings - /[orgId]/settings/sso/page.tsx — domain policy + enforce-SSO toggle + default-role selector + provider list with add/delete dialog (SAML metadata XML upload field) All pages use the existing dashboard primitives (DashboardHeader, DashboardContent, DashboardGrid, StatCard, Card, Table, Dialog, Switch, Select) so visual conventions match the consultant/admin/staff dashboards. Type-check clean across the whole project.
…in/staff navbars Phase G of PR2 enterprise foundation. The switcher is the integration point that lets a user with both a personal dashboard and one or more org affiliations jump between them without leaving the platform. - components/dashboard/OrganizationSwitcher.tsx — DropdownMenu reading session.user.organizationMemberships from the customSession callback. Self-hides when memberships.length === 0, so B2C users with no org affiliation see a navbar that is byte-for-byte identical to today. Each row shows the org name + kind badge + role badge and links into /dashboard/organization/[orgId]/home. The bottom item routes to the landing page for org creation/management. - components/dashboard/DashboardNavbar.tsx — drop the switcher next to the NotificationInbox. This is the navbar used by both consultant and consultee dashboards, so the integration is one edit covering both. - app/dashboard/admin/layout.tsx and app/dashboard/staff/[staffId]/layout.tsx — drop the switcher into the sticky top header next to NotificationInbox. The CollapsibleSidebar layout doesn't have a DashboardNavbar, so the integration is layout- level for these two roles. The personal consultant + consultee dashboards (~22 inner pages combined) get zero structural changes — the dashboards keep all their queries, Stream integration, auth gates, and routing. The only delta is an extra dropdown in the navbar that is invisible for users with no org memberships.
Phase H of PR2 enterprise foundation.
- app/organizations/invite/[token]/page.tsx — public route (outside the
/dashboard tree, so middleware does not gate it). Branches on session:
* Unauthenticated → renders sign in / sign up CTAs that preserve the
token via callbackUrl, so a brand-new user signs up and lands back
here with a session cookie ready to accept.
* Authenticated → POSTs the token to
/api/organizations/invitations/accept (built in Phase E) and routes
the user into /dashboard/organization/[orgId]/home on success.
Handles the alreadyMember case (idempotent re-acceptance), expired
tokens, and email-mismatch errors with friendly inline messaging.
Phase I of PR2 enterprise foundation. Extends the checkout schema and handler to accept an optional organizationId. When present: 1. schemas/checkout.ts — adds `organizationId: z.string().optional()` to `checkoutSchema` and the `createCheckoutData` utility 2. lib/payments/operations/checkout.ts — at the top of `handleCheckout`, resolves the OrganizationProfile by `organizationId` and reads `billingMode`. For TAG_ONLY (the default BUYER mode), the only change is setting `organizationProfileId` on the Payment row so the org billing dashboard can query tagged payments. SEAT_PACK → credit deduction will branch here in Phase J. INVOICED_MONTHLY → invoice rollup will branch here in Phase K. PROVIDER 3-way earnings split → gated by ENABLE_PROVIDER_ORGS (no changes to earnings-service.ts in this commit; BUYER split is unchanged). The `orgBillingMode` variable is captured but unused until Phases J/K; eslint-disable-next-line suppresses the no-unused-vars warning.
…teway
Phase J+K (partial) of PR2 enterprise foundation. Wires the two non-gateway
org billing modes into the checkout transaction.
New module: lib/payments/operations/org-credits.ts
- deductCredits(tx, orgProfileId, amountPaise, paymentId?)
— decrements OrgCreditPool.balance inside the caller's Prisma TX,
writes an immutable OrgCreditLedger row, throws if insufficient.
- creditRefund(tx, orgProfileId, amountPaise, paymentId?)
— reverses a deduction (used by Phase M refund handler).
- purchaseCredits(tx, orgProfileId, creditsPaise, paymentId?)
— grants credits after a gateway webhook confirms a credit-pack buy.
Checkout handler changes (lib/payments/operations/checkout.ts):
- Detects `orgBillingMode === "SEAT_PACK"` → sets
`isOrgCreditPayment = true`, generates synthetic payment ID
(org_credit_*), skips PaymentIntent, marks Payment as SUCCEEDED
immediately, sets paymentMethod = "ORG_CREDIT".
- Inside the Serializable transaction: calls `deductCredits()` after
the Payment row is created — the pool balance check + decrement is
atomic with the booking, so concurrent checkouts cannot overdraw.
- Detects `orgBillingMode === "INVOICED_MONTHLY"` → sets
`isOrgInvoicedPayment = true`, generates synthetic payment ID
(org_invoiced_*), skips PaymentIntent, marks Payment as SUCCEEDED
immediately, sets paymentMethod = "ORG_INVOICED".
billableToOrgInvoiceId stays null until the monthly Inngest cron or
the manual generate-invoice endpoint (Phase E) rolls it up.
Both modes:
- skipPayment = true → slots are confirmed immediately (no webhook)
- isMockPayment flag set so the post-processing branch (earnings,
referrals, waitlist) runs inline instead of waiting for a webhook
- organizationProfileId is set on the Payment row for billing queries
TAG_ONLY (Phase I) is unchanged — normal gateway flow with org tag.
Phase L of PR2 enterprise foundation. Instead of querying Prisma from
edge middleware (not edge-compatible), the SSO domain routing is done
via a public API route that the signin page calls client-side.
GET /api/auth/sso/domain-check?email=alice@acme.com
1. Extracts the domain from the email
2. Queries OrganizationSSOSettings for enforceSSO=true + domain match
3. Looks up the linked SsoProvider
4. Returns { enforceSSO: true, providerId, ssoSignInUrl } or
{ enforceSSO: false }
The signin page can use this response to redirect the user to the IdP
instead of showing the password form. The ssoSignInUrl points to
BetterAuth's /api/auth/sso/sign-in/[providerId] which handles the
SAML/OIDC flow.
Route is public (falls under the existing /api/auth/ prefix in
middleware's PUBLIC_API_PREFIXES). Added a clarifying comment to the
prefix entry.
Phase M of PR2 enterprise foundation. Extends the two-phase refund handler in /api/payments/refunds to branch on paymentMethod before calling the gateway. - ORG_CREDIT (SEAT_PACK): calls `creditRefund()` from org-credits.ts to credit the pool balance back. No gateway call — purely a ledger reversal. The OrgCreditLedger row records the positive delta with reason "refund". - ORG_INVOICED (INVOICED_MONTHLY): if the payment is still linked to an unpaid invoice (billableToOrgInvoiceId set), unbills it so it drops out of the next invoice aggregation. If the invoice was already paid, the refund is marked SUCCEEDED and the admin issues a manual credit note on the next billing cycle. - CARD / TAG_ONLY / B2C: standard gateway refund path unchanged. Also includes organizationProfile in the Phase-1 Prisma select so the org fields are available in the refund routing logic.
Phase N of PR2. Documents the four locked decisions: 1. Same dashboards + OrgSwitcher (no new layouts) 2. PROVIDER deferred via ENABLE_PROVIDER_ORGS feature flag 3. All three BUYER billing modes (TAG_ONLY, SEAT_PACK, INVOICED_MONTHLY) 4. Full SSO admin UI via domain-check endpoint The three existing enterprise docs already carry SUPERSEDED banners from PR1. The canonical design doc (00-canonical-design.md) is left as a follow-up since the PR itself is the canonical source of truth.
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Code Review
This pull request introduces the foundational infrastructure for enterprise functionality, including organization profiles, member management, and multi-tenant dashboard support via a new OrganizationSwitcher component. It implements three primary billing modes—TAG_ONLY, SEAT_PACK, and INVOICED_MONTHLY—and integrates SSO capabilities through BetterAuth. Key feedback includes a recommendation to use cryptographically secure sources for invoice number generation and a warning regarding potential precision issues when using floating-point arithmetic for currency rate calculations.
Adds enterprise organization seeding as Phase 15 in the seed pipeline and fixes schema issues found during seeding prep. New: prisma/seedFiles/15a-create-organizations.ts - Creates orgs across all 3 billing modes (TAG_ONLY, SEAT_PACK, INVOICED_MONTHLY) scaled by SEED_MODE (small: 4, medium: 8, large: 15) - Each org gets: owner + 3-10 members (ORG_ADMIN, ORG_MANAGER, ORG_LEARNER), org plans, pending invitations - SEAT_PACK orgs get a seeded credit pool with purchase + booking ledger entries, pool balance reconciled to match the ledger - INVOICED_MONTHLY orgs get a sample invoice (DRAFT/SENT/PAID) - ~30% of orgs get SSO settings with an allowed domain - One memberless "Platform Admin Test Org" for testing admin bypass Updated: prisma/seedFiles/config.ts - Added organizations, membersPerOrg, plansPerOrg to VolumeConfig - Added org count to printConfigSummary output Updated: prisma/seed.ts - Imports + calls createOrganizations(users) as Phase 15 Schema fixes (prisma/schema.prisma): - Removed @unique from OrganizationMemberProfile.consultantProfileId and .consulteeProfileId — a consultant/consultee CAN belong to multiple orgs, so these must be non-unique FKs - Changed ConsultantProfile.organizationMemberProfile → plural organizationMemberProfiles (1:many back-relation) - Same for ConsulteeProfile - Added indexes on consulteeProfileId and consultantProfileId Bug fixes: - members/route.ts: reactivate REMOVED members instead of 409 when re-adding. The old code rejected re-adds even after soft-remove, making it impossible to re-invite someone. - checkout.ts: added "insufficient credits" to preserved error messages so SEAT_PACK pool errors surface as 400 not 500 - payment-error-classification.ts: classify "insufficient credits" as AVAILABILITY (400) not generic server error (500) New: prompts/enterprise-tests/e2e-enterprise-agent-001-org-lifecycle.md - 13-phase E2E test prompt for Supabase MCP + Chrome DevTools MCP Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a polished 5-step organization creation wizard matching the
individual onboarding experience, fixes the invitation token loss bug,
and adds an org image upload endpoint.
Organization Setup Wizard (/dashboard/organization/create)
- 5 steps: Org Info → Billing & Seats → Branding → Invite Team → Review
- Same stepper UI as individual onboarding (green circles + connectors)
- Step 0 creates the org on "Next" (so step 2 has an orgId for uploads)
- Step 1 PATCHes billing mode, payment terms, seat budget
- Step 2 uploads logo/banner via new image API, PATCHes colors
- Step 3 bulk email input with validation, dedup, per-email chips
- Step 4 review with edit links + parallel invitation sending
- Steps 2+3 have "Skip for now" option
- Landing page (/dashboard/organization) simplified: inline form
replaced with Link to the wizard
Image Upload API (POST /api/organizations/[orgId]/images)
- ORG_ADMIN+ auth via requireOrgAccess
- Accepts FormData: file (JPEG/PNG/WebP, max 5MB) + type (logo|banner)
- Uploads to Supabase bucket "organizations" at {orgId}/{type}-{uuid}
- Updates Organization.logo and/or OrganizationProfile branding fields
Invitation Flow Fix (3 files)
- app/organizations/invite/[token]/page.tsx: stores token in
localStorage before redirecting to signup (bridges the signup →
onboarding → dashboard chain where callbackUrl gets lost)
- app/auth/signup/page.tsx: reads callbackUrl from searchParams, passes
it through to /form/onboarding and SocialLoginButtons
- app/form/onboarding/page.tsx: after onboarding success, checks
localStorage for pendingOrgInviteToken (primary) and callbackUrl
query param (fallback) before the role-based dashboard redirect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChatGPT 5.4 codex feedbackI walked the enterprise docs ( 1. Checkout currently trusts any client-supplied
|
…ssages BetterAuth's server-side validation returns raw error strings like "[body.email] Invalid email address; [body.password] Too small: expected string to have >=1 characters" which were being dumped directly into the toast notification. Added friendlyAuthError() to both signin and signup pages that: - Detects email validation errors → "Please enter a valid email address." - Detects password length errors → "Password must be at least 8 characters." - Detects duplicate accounts → "An account with this email already exists." - Detects invalid credentials → "Invalid email or password." - Strips [body.field] prefixes from any unrecognized errors as fallback
Adds a dedicated enterprise onboarding path so org administrators can
sign up and create their organization in one flow, without going through
the irrelevant CONSULTEE onboarding (career goals, skills, budget).
Schema
- prisma/schema.prisma: add ORG_ADMIN to UserRole enum
Onboarding schemas (utils/onboarding.ts)
- Add orgAdminFormFields to the form-level discriminated union with org
fields (orgName, orgBillingEmail, orgBillingMode, etc.)
- Add ORG_ADMIN branch to OnboardingDataSchema (server payload schema)
- Extend OnboardingFormData type to include org fields
Server processing (utils/onboarding-server.ts)
- Add ORG_ADMIN case in upsertProfileByRole (returns empty — no personal
profile created)
- Add createOrgForOnboarding() helper that atomically creates
Organization + OrganizationProfile + Member(ORG_OWNER) +
OrganizationMemberProfile, OrgCreditPool for SEAT_PACK, and fires
off invitations
- Return orgId in the response so the client redirects to the org dashboard
- ORG_ADMIN is NOT blocked by the STAFF/ADMIN invite-only guard
Onboarding UI (app/form/onboarding/)
- PersonalInfoAndRoleForm.tsx: add "Organization Admin" as 3rd role card
("Set up an org for your school or company")
- page.tsx: add ORG_ADMIN step labels ["Personal Info", "Organization
Setup", "Review & Launch"], step rendering, and post-submit redirect
to /dashboard/organization/[orgId]/home
- OrgAdminOrgSetupStep.tsx (new): merged org wizard — org name, billing
email, billing mode, description, industry, size, website, payment
terms, seats, plus bulk email invite with validation and chips
- OrgAdminReviewStep.tsx (new): summary sections with edit links,
"Create Account & Organization" button
Dashboard routing (app/dashboard/page.tsx)
- Add ORG_ADMIN case: routes to first org dashboard from session
memberships, or /dashboard/organization if no orgs yet
OrganizationSwitcher
- Always visible for ORG_ADMIN users (even with 0 orgs) so they can
access "Create or manage organizations"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- app/api/organizations/[orgId]/route.ts: add billingMode to the PATCH schema so the org wizard's review step can persist the billing mode selected in step 1 - ReviewStep.tsx: include billingMode in the final confirmation PATCH - CollapsibleSidebar.tsx: truncate long org names in the sidebar header (flex-1 min-w-0 truncate) to prevent layout overflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nsform
Three bugs fixed:
1. z.literal(UserRole.ORG_ADMIN) → z.literal("ORG_ADMIN" as const) in
OnboardingDataSchema and orgAdminFormFields. The Prisma enum value was
undefined in the stale .next server webpack cache, making the discriminated
union unable to match "ORG_ADMIN" role — using a hardcoded string avoids
any module-load-time enum resolution issues.
2. Both ORG_ADMIN switch cases in transformOnboardingFormToServerData and
transformFrontendToServerData now use dual case labels (UserRole.ORG_ADMIN
+ "ORG_ADMIN" as any) and pass role: formData.role to avoid Prisma enum
dependency.
3. transformOnboardingFormToServerData ORG_ADMIN case now includes all org
fields (orgName, orgBillingEmail, orgBillingMode, etc.) so they reach the
server-side createOrgForOnboarding function.
DB migration also applied: ALTER TYPE "UserRole" ADD VALUE 'ORG_ADMIN'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…variants Addresses 3 legit findings from PR #655 code review (ChatGPT 5.4 codex feedback via teetangh). 2 BS gemini-bot comments ignored. 2 SSO integration gaps documented as TODOs (not blockers for this PR). 1. BLOCKER FIX — Checkout org authorization (lib/payments/operations/checkout.ts) The checkout handler trusted any client-supplied organizationId without verifying the caller is an active member of that org. Any logged-in user could pass another org's ID and drain their credit pool (SEAT_PACK), push bookings onto their invoice (INVOICED_MONTHLY), or tag payments (TAG_ONLY). Fix: before applying any org billing mode, verify the caller has an active OrganizationMemberProfile for the org. Fail closed — reject with "You are not an active member" if no membership found. 2. FIX — Member PATCH seat/profile invariants ([orgId]/members/[memberId]) Changing a member's role (e.g., ORG_LEARNER → ORG_ADMIN) or status (ACTIVE → SUSPENDED) previously did not reconcile seatsUsed, seatAssignedAt, or consulteeProfileId/consultantProfileId FKs. Fix: compute wasSeatOccupying vs isSeatOccupying inside one transaction. Increment/decrement seatsUsed, set/clear seatAssignedAt, update profile FKs to match the new role. 3. FIX — seatsTotal enforcement ([orgId]/members + invitations/accept) seatsTotal was configurable and displayed but never enforced — orgs could exceed capacity freely. Fix: check seatsUsed >= seatsTotal before admitting ORG_LEARNER members in both: - POST /api/organizations/[orgId]/members (add existing user) - POST /api/organizations/invitations/accept (invitation flow) Returns 403 with a clear message when capacity is reached. 4. TODO — SSO provider config shape + auto-provisioning gap Added detailed TODO comments documenting that: - Provider registration stores raw samlConfig/oidcConfig strings; BetterAuth may expect structured JSON at runtime - SSO auto-provisioning creates a BetterAuth member but NOT the OrganizationMemberProfile the app requires for requireOrgAccess - The ssoSignInUrl in domain-check may not match BetterAuth's actual SSO entrypoint These are known SSO integration gaps, not blockers for the BUYER billing + org management foundation this PR ships. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChatGPT 5.4 codex feedback (round 2)I did a second full pass over the updated PR diff and the refreshed PR description. There are definitely real improvements here: the client-supplied That said, I still don't think the PR is fully accurate to merge under the current description yet. The remaining issues are narrower now, but they're still correctness issues rather than polish. 1. ORG_ADMIN onboarding is still not actually atomicThe PR description says the onboarding server action creates the user + org + member + invitations atomically. The code does not currently do that.
So if org creation fails after the user update succeeds, the action returns an error but the user has already been converted into Proposed fix:
2. The ORG_ADMIN onboarding path bypasses the validation/gating already implemented in the org APIs
That means this path does not inherit the stronger validation rules from
Proposed fix:
3. Seat-limit enforcement is still incomplete in the update/remove pathsThe add-member and invite-accept paths now check
Proposed fix:
4. The new seat-limit checks are still raceable under concurrencyBoth:
check This is the classic read-check-write race. The earlier review comment asked for transactional enforcement, and the current implementation is still only partially there. Proposed fix:
5.
|
…lidation Addresses 5 legit findings from round 2 review (ChatGPT 5.4 codex). 1. FIX — Onboarding bypasses API validation (onboarding-server.ts) orgBillingMode and orgInviteRole were accepted as plain strings without the same enum/PROVIDER-gate validation the API routes enforce. Added billingMode whitelist and PROVIDER role gate before org creation. 2. FIX — Seat capacity not checked in PATCH (members/[memberId]) Promoting a member to ORG_LEARNER via PATCH bypassed seatsTotal. Added pre-check + transactional re-check when gaining a seat. 3. FIX — DELETE decremented seats for non-active learners Repeated DELETEs or deleting suspended/removed learners could push seatsUsed below 0. Now only decrements when target was actually occupying a seat (role=ORG_LEARNER AND status=ACTIVE). 4. FIX — Seat checks raceable (members/route.ts + invitations/accept) The pre-transaction seatsUsed check could be passed by two concurrent requests. Added transactional re-read of seatsUsed/seatsTotal inside the same TX that writes the member + increments the counter. 5. FIX — billingMode mutable after payments ([orgId]/route.ts) billingMode could be PATCHed freely even after live payments existed, creating inconsistent billing state. Added a guard: if any payments are tagged to this org, reject the billing mode change with a clear error message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
ChatGPT 5.4 codex feedback (round 3) I did another pass through the updated diff, especially the places called out in the earlier reviews. A couple of important things are definitely better now: the direct org-funded checkout auth hole in I do still see a few correctness gaps that I think are worth fixing before calling the enterprise foundation complete:
Proposed fix: move ORG_ADMIN org creation into the same transaction as the user/profile update, and validate the org payload up front with the same Zod schemas/enums the org APIs use instead of re-checking a couple of string fields after commit. Invitation row creation should also be part of the same transaction if the onboarding promise is “create org + owner + invites atomically”; only email delivery should stay out-of-band.
Proposed fix: make seat acquisition a single atomic state transition, e.g.
Proposed fix: either parse/normalize the submitted SAML/OIDC data into the exact BetterAuth provider schema before writing the row, or stop writing the table directly and go through whatever official BetterAuth registration path/hook exists for providers. Until then, I would narrow the PR description from “full SSO admin UI” to “SSO settings/provider scaffolding”.
Proposed fix: verify the real BetterAuth SSO sign-in endpoint and generate that exact URL/contract from Overall: this is closer, and the checkout authorization fix is a meaningful improvement, but I still would not describe the PR as fully atomic onboarding + complete seat enforcement + full SSO admin UI yet. The remaining work is mostly concentrated in onboarding transaction boundaries and the SSO contract, which is good news because it is narrow enough to finish cleanly in this PR. |
Addresses round 3 feedback. Two structural improvements:
1. ORG_ADMIN onboarding is now truly atomic (onboarding-server.ts)
- Moved org creation (Organization + OrganizationProfile + Member +
OrganizationMemberProfile + OrgCreditPool) into the SAME Prisma
transaction as the user update. If org creation fails, the user is
NOT left as ORG_ADMIN with no org.
- Org field validation (billingMode whitelist + PROVIDER role gate)
runs BEFORE the transaction, so bad payloads are rejected before
any DB state is mutated.
- Invitations remain fire-and-forget AFTER the TX commits (email
delivery is best-effort, not part of the atomicity contract).
- Replaced createOrgForOnboarding (separate TX) with
createOrgInTransaction (takes a TX client parameter).
2. Race-safe seat enforcement (new: lib/api/organizations/seat-helpers.ts)
- acquireSeat(tx, orgProfileId) — single atomic conditional UPDATE:
`SET seatsUsed = seatsUsed + 1 WHERE seatsTotal IS NULL OR
seatsUsed < seatsTotal`. Returns false when at capacity. Two
concurrent calls cannot both succeed.
- releaseSeat(tx, orgProfileId) — `SET seatsUsed = GREATEST(
seatsUsed - 1, 0)`. Idempotent, cannot go negative.
- All seat-touching paths now use these helpers:
* POST /members — acquireSeat inside TX, throws SEAT_LIMIT_REACHED
(caught as 403)
* PATCH /members/[memberId] — acquireSeat when gaining seat,
releaseSeat when losing seat
* DELETE /members/[memberId] — releaseSeat only if wasSeatOccupying
* POST /invitations/accept — acquireSeat inside TX
- Removed the old read-check-write pattern (pre-TX count check +
in-TX re-read + separate increment) from all four paths.
- SEAT_LIMIT_REACHED errors are caught and returned as 403 with a
user-friendly message (not generic 500).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
ChatGPT 5.4 codex feedback (round 4, product/UI pass) I did another pass with a more product-manager / UX lens instead of only a backend correctness lens. A lot of the implementation is there now, but there are still a few places where the PR reads more complete than the actual user journey.
Proposed fix: either (a) treat org creation as a draft flow with explicit
Proposed fix: either wire the real gateway flows before merge, or downgrade these CTAs to clearly disabled/coming-soon states and narrow the PR/UX copy so we are not presenting non-functional billing actions as available features.
Proposed fix: either add a billing mode selector to Settings (with the proper server-side safeguards already in place), or change the Credits page copy to direct the user to support/admin help instead of pointing at a control that does not exist.
Proposed fix: add an explicit SAML vs OIDC mode with the right fields for each, or narrow the UI/PR copy to SAML/provider scaffolding until the OIDC path is genuinely supported.
Proposed fix: either add the missing edit/create UI paths now, or scope the PR copy down to what is actually reachable from the dashboard today. From a PM perspective, hidden API capability is not feature-complete until the user journey exists. Overall: the remaining work is less about raw route count now and more about finishing or de-scoping the user journeys so the shipped product matches the story in the PR. The biggest theme is that several flows are built under the hood but still stop short of a coherent, self-serve admin experience. |
…ow SSO copy
Addresses round 4 product/UX feedback:
1. ReviewStep: check PATCH response before redirecting. Previously the
final PATCH was fire-and-forget — a failing finalization could look
like a successful launch. Now throws on non-OK response.
2. Credits "Buy credits" button: disabled with "(coming soon)" label
instead of looking functional and returning a stub alert. Updated
the "wrong mode" copy to not point at a non-existent Settings control
("Switch billing mode in Settings" → "set during creation").
3. Billing "Pay" button on invoices: disabled with "(coming soon)" label
instead of triggering the pendingPhaseK stub flow.
4. SSO page: narrowed copy from "SAML / OIDC" to "SAML supported; OIDC
coming soon" in both the header and provider list description.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
ChatGPT 5.4 codex feedback (round 5, billing-model pass) I did a focused pass on the organization billing algorithms and role matrix across checkout, credits, invoicing, refunds, analytics, and the org dashboard permissions. Structurally this is much better than the first iterations, but there are still a few places where the billing model is not yet internally consistent.
Proposed fix: centralize a single “billable org payment” predicate/helper and use it consistently. At minimum: require succeeded payments for billing/analytics, and decide explicitly whether refund-adjusted revenue should be gross or net in each surface.
Proposed fix: invoice generation should only roll up succeeded org-invoiced payments, and it should invoice the remaining billable balance (
Proposed fix: when refunding an ORG_INVOICED payment that is already attached to an invoice, either recompute the invoice totals/items transactionally or create an explicit credit-note / adjustment entry so the ledger remains auditable and invoice totals stay correct.
Proposed fix: enforce the limit in the same place org-funded checkout is authorized. Before accepting an
Proposed fix: codify the intended spending policy explicitly. If any active member may spend org budget, document that. If only specific roles (for example active
Proposed fix: trace the actual consultee/org-member booking UI and either wire Overall recommendation: pull org billing math into a dedicated shared service that owns these invariants:
Right now those rules are spread across checkout, invoice generation, refunds, analytics, and dashboard summary code, which is why the edge cases are diverging. |
…rcement Addresses round 5 billing-model-focused feedback: 1. FIX — Analytics/billing filter by paymentStatus=SUCCEEDED billing/route.ts: month-to-date gross + pending charges now require paymentStatus=SUCCEEDED. Pending charges also require paymentMethod=ORG_INVOICED (not all org-tagged payments). analytics/route.ts: all 3 payment queries (this month count, last month count, revenue aggregate) now require SUCCEEDED. 2. FIX — Invoice generation filters + refund netting generate-invoice/route.ts: only rolls up SUCCEEDED + ORG_INVOICED payments. Each payment's billable amount is now net of successful refunds (payment.amount - sum(refunds.amount)). Fully-refunded bookings are excluded from the invoice entirely. 3. FIX — orgInvoiceCreditLimit enforcement in checkout checkout.ts: before accepting an ORG_INVOICED booking, calculates current outstanding exposure (unbilled succeeded ORG_INVOICED payments + unpaid SENT/OVERDUE invoices). Rejects when exposure would exceed the org's configured credit limit. 4. DOCUMENTED — Role-blind org spending policy checkout.ts: added explicit comment that any active member may spend org budget (intentional). If role-based restrictions are needed later, the check goes next to the membership verification. 5. DOCUMENTED — Invoice refund adjustment gap refunds/route.ts: added TODO comment explaining that ORG_INVOICED refunds on already-paid invoices do not update the invoice amount. Credit-note / invoice adjustment is a separate feature. 6. ACKNOWLEDGED — No frontend sends organizationId yet Already listed as a follow-up in the PR description. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n, session refresh, IDOR posture)
Closes the P0/P1 findings from the enterprise auth + SSO audit (3 Explore
agents: auth core, SSO end-to-end, multi-tenant isolation).
Phase A — P0 ship-blockers:
A.1 JIT auto-join role floor locked to LEARNER. Pre-fix, an admin
could set `OrganizationSSOSettings.defaultRoleForAutoJoin =
"OWNER"` and the first SSO user became co-owner of the org
instantly — catastrophic privilege grant if the IdP was ever
misconfigured. The new `JitDefaultRoleSchema = z.literal("LEARNER")`
in lib/labels/org-labels.ts rejects every other role with 400
`INVALID_DEFAULT_ROLE`. UI replaces the prior role dropdown with
a locked "Learner" label + helper text. Admins promote
explicitly via /dashboard/.../members (audit-logged).
A.2 SAML cert validation via Node `crypto.X509Certificate`. Pre-fix,
`samlConfigSchema.cert` was `z.string().min(1)` — any garbage
passed. BetterAuth's underlying `@node-saml/node-saml` adapter
crashed at first signin with
`TypeError: Cannot read properties of undefined (reading 'metadata')`
returning 500 with empty body. New `validateSamlCert` refinement
rejects malformed PEM at registration with a friendly error
pointing at where to find the correct cert in the IdP admin
console. Two existing malformed-cert rows soft-deleted with audit
trail.
A.3 customSession bareMembers catch narrowed to P2002. The bare
`catch {}` swallowed network drops, Supabase RLS denials, FK
violations — users could end up with a session cookie but no
Membership row, landing on a broken dashboard. Now only
`PrismaClientKnownRequestError` with code `P2002` is swallowed
(concurrent session-create won the membership); everything else
re-throws.
Phase B — P1 hardening:
B.1 SSO error toast wrapper. New `lib/sso/signin-with-toast.ts`
wraps `signIn.sso()` with: (a) BetterAuth `{ data, error }`
inspection (the SDK never throws on logical failure), (b) 2s
redirect watchdog (catches 500-with-empty-body crashes), (c)
generic destructive toast on either failure. Replaces 3 inline
`await signIn.sso(...)` sites in signin/signup pages that
previously had no error handling.
B.2 ACS URL type inference fixed. providers/route.ts hardcoded
`type: null` meant OIDC providers showed the wrong setup
instructions. Now derived from `samlConfig` / `oidcConfig`
presence.
B.3 Domain-verification gate at provider registration. The POST
handler now requires (a) an `OrgDomainClaim` row exists for the
domain owned by THIS org, and (b) `verifiedAt IS NOT NULL`. Pre-
fix, an org could register a provider for an unverified domain
(or even one owned by another org) — the runtime hook would
silently refuse to honor it, leaving admins staring at a
registered provider that mysteriously never fires. Now: 422
`DOMAIN_NOT_OWNED` / `DOMAIN_NOT_VERIFIED` at the registration
step itself.
B.4 SsoProvider composite unique. Added `@@unique([organizationId,
domain])`. Belt-and-suspenders for B.3's route-level check —
future hand-SQL / migration bypasses are now caught at the DB
level.
B.5 sessionGeneration refresh plumbing. New `User.sessionGeneration
Int @default(0)`. Every membership mutation that affects the
effective permission set (PATCH role/status/departmentLabel,
DELETE soft-remove, POST add, invitation accept) calls
`bumpUserSessionGeneration(tx, userId)`. The customSession hook
always re-reads memberships, and now carries the live
`sessionGeneration` in the session payload for client-side
stale-detection. Pre-fix, a LEARNER promoted to OWNER (or an
OWNER demoted) kept their old permissions for up to 24h until
BetterAuth's session rotation window passed.
B.6 Extracted shared `lookupEnforcedOrg` to lib/sso/enforce-session.ts.
Three call sites (session.create.before, customSession,
domain-check route) previously duplicated the domain →
OrgDomainClaim → SsoProvider lookup with subtle drift (issue
#673). Single source of truth now.
B.7 N+1 fix in customSession membership sync. Pre-fetch the user's
`consulteeProfileId` + `consultantProfileId` once at the top of
customSession; pass through to `applyMembershipRoleEffects` via
a new `preloadedProfiles` argument. For a user in 10 SSO orgs,
avoids 10 redundant `users.findUnique` round-trips per session
lookup.
B.8 Rate-limit policy documented. BetterAuth's built-in limiter
stays disabled (per-process counters drift on serverless); the
Upstash-backed `authLimiter` in middleware.ts:192-197 provides
the globally-coherent gate at 10 req / 15 min / IP. Added a
multi-line comment block at lib/auth.ts:31 explaining the
decision + listing the coverage matrix.
Phase C — Tests:
Extended prompts/enterprise-tests/1-membership-auth/1.3-sso-and-domain-claims.md
with 5 new MCP-driven cases (SSO.6 through SSO.10) covering the
cert-validation reject, the domain-verification 422s, the JIT-role
400, the domain-check rate limit, and the role-change session
refresh.
Phase D — Docs:
- docs/enterprise/08-sso-and-authentication.md extended with JIT
default role section, cert rotation procedure, cert format
validation, IdP email-verification operator guidance, and
cross-links to the new reference docs.
- docs/enterprise/28-jit-and-session-refresh.md (NEW). Full sequence
of JIT auto-join, the sessionGeneration marker, role-change
refresh without forced logout. Includes sequence diagram + failure
modes + how-to-detect.
- docs/enterprise/30-rate-limiting.md (NEW). Coverage matrix +
rationale for the BetterAuth-disabled policy.
- docs/enterprise/reference/sso-error-codes.md (NEW). Every typed
HTTP error code emitted by the SSO routes, paired with the
humanizeOrgError copy + operator fix.
- docs/enterprise/00-overview.md index extended with the 3 new docs.
- prompts/enterprise-tests/_shared/shared-setup.md §8 key-code-anchors
extended with the new audit-batch surfaces.
All inline JSDoc / block comments in the code carry the WHY (failure
modes, upstream pointers, why-not-the-other-design rationale) per the
plan's "Shape 1 / 2 / 3" depth bar. New developers reading these files
cold should be able to infer the constraints without spelunking git
history.
Out of scope (deferred):
- P2 code-health: isAtLeastRole undefined guard, unified auth-helper
error shape, OrgAuditLog DB-level CHECK constraint
- DPDP user self-service data export
- Single Logout (SLO) for SAML / OIDC front-channel
- OIDC scopes default + IdP-specific docs
Schema migration: 20260516000000_auth_sso_hardening adds
User.sessionGeneration + SsoProvider composite unique. Phase A.1 data
normalization (defaultRoleForAutoJoin → LEARNER) applied directly via
Supabase MCP since prisma/migrations is gitignored in this repo.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI green-up (876/883 → 957/957): - stream/user-actions: add consentArtifact delegate to mock - enterprise/member-anti-lockout: wire tx.user.update + sessionGen assertion - sso/provider-schemas: inline real RSA-2048 PEM; pin 2 negative cases - enterprise/programs-v2-rejection: switch jest.mock specifiers to relative - jest.config: skip .claude/worktrees + __tests__/fixtures during crawl Critical bug fixes (4 reviewer findings, May 2026 audit): - SSO cert pre-auth guard: domain-check route parses stored cert via validateSamlCert before handing the client an ssoBody; returns typed SSO_PROVIDER_MISCONFIGURED so BetterAuth's adapter never crashes on malformed legacy PEM rows - PO balance enforcement test surface: 6-case regression covering CAS decrement, exact-balance drain, overflow rejection, VOID restoration - UI error humanization: 6 new ORG_ERROR_COPY entries (PROGRAM_TYPE_NOT_AVAILABLE, PO_BALANCE_EXCEEDED / INSUFFICIENT, DOMAIN_NOT_OWNED / VERIFIED, SSO_PROVIDER_MISCONFIGURED) + snapshot test - Prisma 7 standalone jobs: dotenv/config + $disconnect across the five jobs/**/* entry points that were missing them Schema migration (applied via Supabase MCP under pr655_enterprise_lockdown_schema): - MemberRole += BILLING_ADMIN at rank 70 (between MAINTAINER 80 + MANAGER 60) - MemberStatus += ERASED (DPDP §12 tombstone) - OrgAuditCategory += WEBHOOK - User += erasedAt + pseudonymousId (unique, indexed) - Membership += externalScimId (partial-unique scoped by orgId) - Organization += streamRecordingRetentionDays @default(90) - 6 new tables: WebhookEndpoint, OutboundWebhookDelivery, ScimToken, ScimGroupMapping, ErasureRequest, OrgDataExportJob - 5 new enums: WebhookEndpointStatus, DeliveryStatus, ScimTokenStatus, ErasureStatus, OrgDataExportStatus BILLING_ADMIN role split (finance-team operator): - ORG_ROLE_RANK[BILLING_ADMIN] = 70; rationale in role-ranks.ts - New requireOrgBillingAdminOrOwner disjunction helper (explicit role match, NOT rank-based — MAINTAINER at rank 80 must be denied) - 12 route gate downgrades from OWNER-only: billing-account/route.ts, purchase-orders (POST + [poId]), invoices (POST + [invoiceId] PATCH + pay), wallet/top-ups POST, payouts (POST + [payoutId] PATCH), rate-cards (POST + [cardId]) - Stays OWNER-only: org DELETE, sso/**, domain-claims/**, members/**, invitations/** - Labels + Zod enums updated; create-wizard role picker includes BILLING_ADMIN - 9-case billing-admin-gate.test.ts pins the disjunction (MAINTAINER rank-80 must NOT pass) - canHost: true now hard-gated server-side with HOST_ORGS_GATED (replaces WIP-banner pattern per reviewer feedback) Docs: 04-roles-and-permissions.md gate matrix; 08-sso cert validation runtime guard; 10-invoicing PO balance invariant; 23-runbooks local cron run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the integrator-polling gap so external systems (HRIS, finance ERPs, customer-success tools) can react to org lifecycle events without scraping the dashboard. Subsystem (lib/enterprise/outbound-webhooks/): - event-types.ts — closed catalog of 8 events (member.added/removed, invoice.issued/paid, payout.completed/failed, contract.signed, program.assigned). Adding events is a one-line edit + emit-point. - signing.ts — HMAC-SHA256 with Stripe-shaped t=<unix>,v1=<hex> header. 9h replay window matches the worker's max retry wall-clock. Constant-time verifier via crypto.timingSafeEqual. - dispatch.ts — fire-and-forget DB enqueue. Filters subscribed ACTIVE endpoints via `eventSubscriptions has $eventType`; safe inside or outside Prisma transactions (commits with the caller). - worker.ts — runDispatchTick: pulls up to 50 PENDING/RETRY rows, signs + POSTs with 10s timeout, exponential backoff (1m→5m→30m→2h→8h then FAILED). Permanent 4xx (not 408/429) skip retry; 5xx/408/429/network errors get the next backoff slot. PAUSED endpoints short-circuit to FAILED — operator pause wins. - audit.ts — one summary row per delivery final state (not per attempt); keeps the audit log readable. Routes (7 under app/api/organizations/[orgId]/webhooks/): - GET / (MANAGER+) — redacted list - POST / (OWNER + BILLING_ADMIN) — one-time secret reveal on create - PATCH /[endpointId] — pause/resume + URL/event edits - DELETE /[endpointId] (OWNER only — cascade-deletes pending deliveries, governance-sensitive) - POST /[endpointId]/rotate-secret (OWNER only) - GET /[endpointId]/deliveries (MANAGER+) — paginated log with payload included for receiver-side debugging - POST /[endpointId]/deliveries/[deliveryId]/redeliver (OWNER + BILLING_ADMIN) Worker cron: jobs/cleanup/dispatch-outbound-webhooks.ts + .github/workflows/dispatch-outbound-webhooks.yml (every minute). HTTP shim at app/api/cleanup/dispatch-outbound-webhooks (CRON_SECRET gated). Emit-points wired: - membership-transitions: member.added / member.removed (in-app invites). Dispatched inside the same tx so a rollback also rolls back the delivery row. - billing-account/invoices POST: invoice.issued (only on the DRAFT→ISSUED path; a no-op PATCH doesn't re-emit). - More emit-points (invoice.paid, payout.*, contract.signed, program.assigned) are scaffolded; remaining wire-ups land in a follow-up. Rate limit: orgWebhookLimiter (5/min per org) on POST/PATCH/rotate. 19-case test suite (signing roundtrip, replay rejection, dispatch fan-out, worker retry schedule, operator-pause short-circuit). Docs: docs/enterprise/29-outbound-webhooks.md — event catalog, signature scheme, retry table, webhook.site integration recipe. Live-verified end-to-end against production schema (Supabase MCP) during PR #655 smoke run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Off-the-shelf IdP connectors (Okta, Azure AD, OneLogin, JumpCloud) hard-code /scim/v2/<resource> as the canonical path; this commit mounts the spec-compliant subset at app/scim/v2/ so those connectors work without custom configuration. Path placement is deliberately outside /api/ because IdPs reject non-spec paths; capital-cased segments (/Users, /Groups, /Schemas) match RFC 7644 §3.2 — some IdP clients are case-sensitive on these. The middleware short-circuits with NextResponse.next() for any path under /scim/v2/ before BetterAuth's session-cookie check; the route handlers self-authenticate via bearer tokens. Subsystem (lib/scim/): - auth.ts — bearer parser; sha256 hash lookup against ScimToken; bumps lastUsedAt (fire-and-forget); writes SCIM_TOKEN_USED_AFTER_REVOKE audit row when a revoked token authenticates (so rotation gaps are visible without grepping worker logs). Enforces scimLimiter (60 RPM per token). - resource-user.ts — toScimUser mapper + resolveRoleFromGroupNames (highest-rank wins; LEARNER default for least-privilege). - operations.ts — createOrReprovisionScimUser (idempotent upsert by (orgId, userName)) + deprovisionScimUser (DELETE = SUSPEND, never erase — erasure is the user's own DPDP §12 right). Every mutation dispatches member.added / member.removed via the outbound-webhook helper so subscribers see IdP-driven and in-app provisioning through the same socket. - errors.ts — SCIM 2.0 error envelope per RFC 7644 §3.12. Erasure short-circuit: every operation that would create or revive a User refuses with 410 Gone when User.erasedAt IS NOT NULL. Surfaces in the IdP's provisioning report as a permanent error. Routes (mounted at app/scim/v2): - GET /Users (list + filter=userName eq "...") - POST /Users (create or re-provision; honors externalId + groups) - GET /Users/[id] - PATCH /Users/[id] (replace active for Okta/Azure deactivate; reject other ops with 400 invalidSyntax to keep the attack surface small) - DELETE /Users/[id] (soft-deprovision → SUSPENDED) Org-scoped admin (under app/api/organizations/[orgId]/scim/, OWNER-only): - tokens (GET list, POST mint with one-time reveal, DELETE revoke soft-flips status to REVOKED keeping the row for the used-after-revoke audit trail) - group-mappings (GET, POST, DELETE) — bind IdP group names (IT-Admins, Finance-Leads, etc.) to local MemberRole Tests (15 cases): - resource-user: shape + parseDisplayName + role resolution (highest-rank wins; unknown groups ignored) - auth: missing header → 401; bogus token → 401; revoked → 401 + audit row; rate-limit → 429; happy path returns grant + bumps lastUsedAt Docs: docs/enterprise/31-scim-provisioning.md — endpoint catalog, auth flow, group→role table, erasure interaction, Okta + Azure curl recipes. Live-verified end-to-end during PR #655 smoke (list / filter / create / PATCH deactivate / DELETE; webhook fan-out confirmed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-PR this was a manual CA + DB-admin process logged in docs/compliance/erasure-requests-manual-log.md. This commit automates it: user files a request, admin reviews, scrub fires inside a single transaction, financial rows are retained per IT Act §44AA, webhooks fan out for IdP deprovisioning, and the manual log is closed. Scrub service (lib/compliance/erasure/scrub-user.ts): - pseudonymousId = sha256(userId + ERASURE_SALT) — deterministic one-way hash used to rewrite audit-log JSON without losing forensic cross-reference - User PII overwrite: name → "Erased User <hash>"; email → erased-<hash>@erased.invalid; image/phone/address/bio/linkedinUrl/ dateOfBirth → NULL; erasedAt + pseudonymousId set - Membership[].status → ERASED across every active org membership - ConsultantProfile.headline + videoIntroUrl scrubbed - BetterAuth Session + Account rows hard-deleted (immediate sign-out across every device, SSO accounts dropped too) - Per-org member.removed webhook fan-out with source: dpdp_erasure, payload uses pseudonymousId NOT raw userId/email - Idempotent: re-running observes erasedAt + returns existing pseudonym without writing Financial rows RETAINED per IT Act §44AA + DPDP §12 carve-out: Payment*, OrganizationInvoice, OrganizationPayout, WalletEntry, FundingLedgerEntry, SettlementLedgerEntry, Refund. API routes: - POST /api/users/me/erasure-requests (idempotent — open request returns the existing row; partial-unique index on (userId, status IN PENDING|IN_PROGRESS) is the DB-side guarantee) - GET /api/users/me/erasure-requests (own status check) - GET /api/admin/erasure-requests (review queue, ADMIN-only) - POST /api/admin/erasure-requests/[id]/process (flip to IN_PROGRESS, invoke scrubUser, mark COMPLETED on success; failure path returns the row to PENDING with the error in `notes` so the queue picks it up again) - POST /api/admin/erasure-requests/[id]/reject (REJECTED + required reason — e.g. financial dispute open) SCIM 410 Gone short-circuit: createOrReprovisionScimUser refuses to revive an erased user. Surfaces in IdP provisioning reports as a permanent error so the operator removes the user from their IdP roster. Audit trail: USER_ERASURE_REQUESTED / PROCESSED / REJECTED / SLA_WARNING under the SYSTEM category, one row per affected org. The audit row outlives the user's identity — it's the regulatory evidence-of-erasure record. Live-verified end-to-end during PR #655 smoke: - Membership.status: ACTIVE → ERASED - User.email: alice@... → erased-94587a24...@erased.invalid - User.name: "Fanout Test" → "Erased User 94587a24" - erasedAt + pseudonymousId populated - ErasureRequest COMPLETED + completedAt + processedByAdminId - member.removed webhook queued with source: dpdp_erasure - USER_ERASURE_PROCESSED audit row written Docs: docs/enterprise/26-deletion-policy.md Phase 2 section rewritten — request lifecycle, scrub field-by-field table, cross-feature interactions (SCIM 410, webhook fan-out, audit retention), no recovery path (deliberate — issue a new User if erasure was an admin error). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… dashboard Closes the remaining⚠️ cross-cutting items from the May 2026 audit plus the BILLING_ADMIN dashboard surface, hygiene cleanup, and the RLS design memo. Security headers (next.config.mjs): - Strict-Transport-Security: max-age=63072000; includeSubDomains; preload - Content-Security-Policy-Report-Only (default) — allow-list covers Stream/Razorpay/Sentry/Supabase/Resend/Upstash; media-src includes blob: + *.getstream.io for call playback; frame-src allows Razorpay checkout iframe - ENABLE_CSP_ENFORCE=true flips to enforcing variant - /api/csp-report sink (unauthenticated; spamLimiter-throttled) emits event: "csp_violation" structured logs Stream call export + retention: - GET /api/organizations/[orgId]/stream/calls (MANAGER+) reads MeetingSession joined to the org via Appointment.organizationId (denormalized #674 column). Optional ?withRecordings=1 lazy-joins Recording. Audits STREAM_CALLS_EXPORTED per call. - Organization.streamRecordingRetentionDays (default 90) + scripts/cleanup/cleanup-old-stream-recordings.ts daily cron at 03:00 UTC tombstones Recordings past the per-org window. Status flips to EXPIRED; underlying Stream S3 lifecycle is Stream's problem (free tier 2w; paid is per-channel). Audit retention (scripts/cleanup/prune-audit-logs.ts, 03:15 UTC): - 7y for INVOICE / PAYOUT / WALLET / CONTRACT / CONSENT (IT Act §44AA financial-record + DPDP §12 evidence-of-erasure) - 2y for MEMBER / SETTINGS / CATALOG / SYSTEM / PROGRAM / WEBHOOK - Per-org AUDIT_PRUNED summary row with deleted-counts; summary itself prunes after 2y (SYSTEM bucket) Stale invitation hardening: - Fixed an over-counter bug in the existing cron: `expired += 1` fired unconditionally outside the transaction even when the concurrency guard short-circuited on a racing accept. Now returns a boolean from inside the tx and increments only on a real flip. - 4-case regression test pins: no-op when no stale rows; happy-path flip + audit row; concurrency guard skip; per-row error isolation. DPDP §11 data export (lib/compliance/erasure not the only DPDP surface — §11 is right-to-access): - POST /api/organizations/[orgId]/data-exports (OWNER + BILLING_ADMIN) — orgDataExportLimiter (1/24h per org) - GET list (last 30 days) + GET [exportId]/download (Supabase signed URL, 7-day TTL; refuses non-READY / expired) - Worker scripts/cleanup/process-data-exports.ts (every 10 min) builds an 8-entity bundle (organization + members + memberships + contracts + programs + invoices + earnings + payouts + audit log), uploads to Supabase Storage bucket `org-exports/{orgId}/{jobId}.json`, emails the requester via Resend. Local-dev fallback writes to /tmp/familiarise-data-exports/ when Supabase env keys aren't set. - Fixed Prisma filter caught during live exercise: Program has no direct organizationId column — programs hang off Contract. BILLING_ADMIN dashboard surface: - New /integrations/ route group with three pages (webhooks, scim, data-exports) gated by useRequireFinanceSurface. Mutation forms render only for OWNER where appropriate (SCIM token + group-mapping creation); listings open to OWNER + MAINTAINER + BILLING_ADMIN + MANAGER. - FinanceLeadViewCard on /home renders for BILLING_ADMIN ahead of the operator branch. Pulls outstanding-invoices + wallet-balance from the existing analytics endpoint; Payouts + POs are deep-link CTAs until analytics ships their breakdowns. - New canSeeFinanceSurface / canSeeOperatorSurface predicates in lib/auth/role-ranks.ts. Sidebar layout switched from rank-only isAtLeast() to the explicit disjunction — fixes a real bug where BILLING_ADMIN (rank 70 > MANAGER 60) was silently granted access to Members / Invitations / Learners / Experts / Audit / Settings. - useRequireFinanceSurface + useRequireOperatorSurface hooks in useOrgRole.ts as the page-level counterparts. WIP banner sweep — per PR #655 reviewer "WIP banners are not production gates": - Removed all three banner render sites in components/organization/create-wizard/OrgInfoStep.tsx + BillingStep.tsx and app/dashboard/organization/[orgId]/programs/page.tsx - canHost: true now hard-gated server-side with HOST_ORGS_GATED 400 (replaces the wizard banner) - Programs V2 page guidance lives in the issue tracker only - Component file components/enterprise/EnterpriseWipBanner.tsx deleted — all consumers removed Hygiene / docs: - docs/enterprise/32-security-headers.md — header inventory + CSP allow-list rationale + rollout plan - docs/enterprise/33-data-export.md — bundle shape + rate limit + download flow - docs/compliance/10-rls-design-memo.md — service-role-bypass pattern, per-table policy sketch, migration risks, three concrete triggers for when to enable RLS (Realtime/Storage client-side, SOC 2 audit, anon-key leak). NOT applied — documented for the next implementer. - docs/enterprise/23-runbooks.md — CSP report-only → enforce flip protocol (7-day observe, log triage, smoke recipe, rollback) - __tests__/fixtures/createBookingWithEarnings.ts — skeleton for the deferred Round-3 booking-flow tests; full implementation requires extracting an earnings-only helper from earnings-service and is tracked as follow-up 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tenance Tier 1 Three discrete groups of fixes from the May 2026 audit's coverage check (run after PR #655 batches 1–8 landed). ## Org-scope filter coverage (6 surfaces, was leaking cross-tenant data) The #674 / B1-hybrid scope split shipped a `resolveOrgScope` helper + denormalized `organizationId` columns on every leaf table, but the rollout was uneven — `requests`, `planner`, `documents`, `recordings` got the filter; legacy routes never did. Every fix here applies the same three-mode shape (personal / org:<id> / all-for-admin) the working endpoints use: - `/api/slots/appointments` — getAppointments() now takes an `orgScopeFilter` argument; consultant/consultee tabs no longer cross-tenant - `/api/dashboard/consultee/[id]/payments` — Payment.organizationId filter; invoice subquery flows the same filter through the payment join - `/api/trials` — TrialSession.organizationId filter - `/api/waitlist` + `lib/waitlist/queue-manager.getUserWaitlistEntries` — Waitlist.organizationId filter via an `orgFilter` param - `/api/referrals` — DELIBERATELY personal-only (referral codes follow the user across orgs); doc-only change recording the intent so future readers don't mistake it for a leak - `components/chat/ChatSidebar.tsx` — the highest-impact leak: `client.queryChannels` had no `custom.organization_id` filter, so a consultant in Acme + Zeta saw every chat cross-tenanted in one inbox. Both initial fetch + paginated load-more now scope by `organization_id` ($exists:false for personal, $eq:<id> for org). `scope` added to fetchChannels deps so the inbox refetches on org-context toggle without a hard reload. ## Schema finalization (3 critical closures, audit recommendation) Locks the schema before pilot customer data lands so future feature work doesn't ship a breaking migration: - `ErasureRequest.processedByAdminId` — added `@relation` FK to User with `onDelete: SetNull` (audit row outlives staff turnover; named relation "ErasureRequestProcessor" disambiguates from the user's own erasure-request edge) - `WebhookEndpoint.secretRotatedAt` + `previousSecretHash` — 24h rotation grace window scaffolding so the worker can verify deliveries signed with either secret during cutover. Column lands now; rotate-secret route still overwrites `secret` directly — flipping to grace-aware reads is a follow-up. - `ScimToken.expiresAt` — optional auto-rotation TTL. Enforcement read is a follow-up; column exists today so OWNERs can set a 6/12-month TTL when minting + the rotation reminder cron has a stable column to scan. Migration applied via Supabase MCP under `pr655_schema_finalization_pre_mvp` (additive; existing rows unaffected). ## Maintenance subsystem — Tier 1 multi-tenant (organizationId + Redis namespacing) Unlocks the per-tenant features in the post-MVP issue without forcing them in now: - `maintenance_windows.organizationId` (nullable FK with `onDelete: Cascade`). NULL = platform-wide window (every legacy row + the default). Non-null = scoped to that tenant; other orgs see the platform stay green. - `lib/maintenance-edge.ts` exports `platformMaintenanceKeys()` + `orgMaintenanceKeys(orgId)` so the future admin route can write org-scoped state under a canonical Redis namespace from day one. Middleware reads the platform key today; the org-aware read is a one-line swap when Tier 2 ships. - Migration applied via `pr655_maintenance_per_tenant_tier1`. ## Constraints / scope - Conflict resolution is INTRA-USER, not INTER-ORG by design. SlotOfAvailability + SlotOfAppointment stay per-user; the dashboard filter is for visibility + finance attribution. - Tier 2 maintenance (per-org admin API, capability scoping, scoped Novu workflows), the SCIM token rotation enforcement, and the webhook secret grace-window verifier all go in the giant post-MVP issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's `tsc --noEmit` step caught what local jest couldn't (jest doesn't typecheck production code; project's standing pref is no-builds-unless- asked, so the earlier batches' TS-errors slipped through). - lib/auth/role-ranks.ts: explicit `Set<MemberRole>` constructor type arg so OPERATOR_ROLES / FINANCE_ROLES don't widen to Set<string> - app/dashboard/organization/[orgId]/audit/page.tsx: list + tone-map WEBHOOK (the OrgAuditCategory enum extension from PR #655 batch 2 required this — the `satisfies Record<OrgAuditCategory, string>` contract was failing in CI) - FinanceLeadViewCard.tsx: StatCard prop is `title` not `label` - integrations/{webhooks,scim,data-exports}/page.tsx: DashboardHeader prop is `subtitle` not `description` - lib/auth-helpers.ts: admin-stub Membership now carries the new `externalScimId: null` column added by PR #655 batch 2 - billing-admin-gate test: runtime `"member" in result` narrowing so TS picks the success branch of the access-grant discriminated union Local `tsc --noEmit` + `jest` both clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… field - ProgramAssignment has no status column in the schema, so the Status column in ManageProgramDialog always rendered a blank badge. Removed the phantom status field from AssignmentListItem and replaced the static render with a date-derived label: Active / Upcoming / Expired.
…nt earnings fix
- 1. ensureConsultantProfile P2002 (membership-transitions.ts)
Seeded users whose ConsultantProfile row existed but User.consultantProfileId was null triggered a unique constraint on ConsultantProfile.userId when the lazy-create ran. Fixed: check ConsultantProfile.findUnique({ where: { userId }}) before create; backfill User.consultantProfileId if found.
- 2. /my-arrangement "Recent earnings" always empty for HOST orgs (my-arrangement/page.tsx)
Original query filtered ConsultantEarnings by payment.organizationId = orgId, but HOST-org payments have organizationId=null (learner pays personally; org relationship lives only on OrganizationEarnings). Fixed: first fetch OrganizationEarnings.paymentId rows for the org, then query ConsultantEarnings by paymentId IN (those IDs).
Scenario (Provider/host-only) now fully walked end-to-end: HOST wizard →
EXPERT add → booking → OrganizationEarnings (10%) + ConsultantEarnings (80%)
confirmed → /my-arrangement shows correct split + recent earnings table.
Two new schema surfaces for the PR #655 closeout features. Both migrations are already applied to the live Supabase DB so prisma generate stays in sync. OrgWorkspaceProfile (20260518000000) gains four operator-preference columns: - defaultLandingOrganizationId — soft FK on the org to open by default; no relation so org deletes don't cascade - notificationRoutingMode (new enum: BELL_AND_EMAIL | BELL_ONLY | EMAIL_ONLY | NEITHER) — read by Novu dispatchers - locale (BCP-47), currencyDisplayCode (ISO 4217) — workspace shell number/date/currency formatting SystemEvent (20260519000000) is a NEW admin-only event table that sits next to OrgAuditLog. Engineering-grade payloads (Prisma stack traces, raw worker errors, internal IDs) live here instead of leaking into org-visible audit rows. OrgAuditLog keeps human prose; SystemEvent keeps the raw error + stack + correlationId. Schema docstrings stay terse — full design lives in docs/enterprise/34-workspace-preferences.md and 35-system-events.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…idebar User-facing surfaces for the PR #655 closeout. Purchase Orders dashboard Full CRUD on /dashboard/organization/[orgId]/purchase-orders backed by the existing API. Modular layout — page.tsx is a thin orchestrator, components/ holds Create/Edit/Delete dialogs + Stat cards + Table, and utils/ holds api + types + formatting. Multi-currency (INR no-decimal, forex 2-decimal), status badges, search, and an audited delete that refuses when contracts/invoices reference the PO. OrgWorkspace operator settings Real settings page replacing the placeholder card. Three independent sections — default landing org, notification routing, locale + currency. Each section has its own Save button so a slow Save in one doesn't block another. Backed by GET/PATCH /api/org-workspace/[id]/settings with an IDOR guard mirroring the workspace billing route. Modular components/ + utils/ layout. Sidebar grouping (CollapsibleSidebar + per-org layout) The 20-item flat sidebar is now five clusters: People / Commerce / Operations / Insights / Configuration. Operations defaults collapsed for OWNER + MAINTAINER (operational data isn't their primary nav), open for MANAGER + SUPPORT. CollapsibleSidebar accepts an optional `groups` prop alongside the existing `items` so the simpler dashboards (staff, admin, workspace) stay on the flat API. Honest role-visibility audit Fixes a real bug — SUPPORT (rank 30) previously saw nothing but Overview because the layout's isAtLeast("MANAGER") gate denied them every operational tab. Now SUPPORT has read access to Members, Operations group, Audit, and Analytics to investigate L1/L2 tickets. Commerce reorder + requiresPO gate Contracts → Purchase Orders → Programs (was Programs → Contracts → POs). Matches the data dependency: every Program FKs to a Contract, so the setup story now reads top-to-bottom. Purchase Orders tab hides in the sidebar when Organization.requiresPO=false — orgs without India AP 3-way-match no longer see the noise. Authz on the PO routes is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…closeout docs
Three-layer fix for org-visible Prisma stack traces in OrgAuditLog:
- lib/enterprise/audit-sanitize.ts: read-side scrub redacts Prisma
error / stack / schema-name patterns before rows hit org surfaces
- lib/enterprise/system-events.ts + app/api/admin/system-events:
new admin-only operational events channel (writes via the new
SystemEvent table, gated by requireAdminAuth)
- scripts/cleanup/process-data-exports.ts +
app/api/organizations/[orgId]/hris/sync/route.ts: failure paths
write a clean prose row to OrgAuditLog and the raw error to
SystemEvent via recordSystemError
- app/api/organizations/[orgId]/audit/{route,export/route}.ts:
list + CSV endpoints pipe descriptions/details through sanitizers
- __tests__/enterprise/audit-sanitize.test.ts: 16 tests, 100% cov
incl. regression for the LicensedSeatConfigNullableScalarRelationFilter
leak pattern from the bug report
Compliance flag gating (default off, returns 404 when unset):
- ENABLE_IRP_UPLOADER on .github/workflows/irp-uploader.yml
(workflow_dispatch still works for manual validation)
- ENABLE_TDS_ADMIN_VIEW on app/api/admin/tds/route.ts (GET+POST)
- ENABLE_HRIS on app/api/organizations/[orgId]/hris/{route,sync,csv-upload}
Razorpay prefill #717: reject mobile numbers with 10+ identical
consecutive digits (e.g. 9999999999 / +919999999999) before opening
checkout — Razorpay test mode rejects these with an opaque modal
otherwise. 17 new tests in __tests__/payments/razorpay-prefill.test.ts.
ENT-4: add PROGRAM_DELETED audit action; the program DELETE site was
reusing PROGRAM_PAUSED and conflating two state transitions for
audit consumers.
Docs:
- PR-655-closeout-audit.md: validation deliverable with kept-vs-deferred
matrix, route inventory, role-coverage table, verification commands
- 34-workspace-preferences.md: OrgWorkspaceProfile preferences design
- 35-system-events.md: SystemEvent vs OrgAuditLog channel separation
- Enterprise-Simplification-Proposal.{md,docx}: phased plan for
~2,800 LoC of bloat removal under the schema-locked constraint
Verification: 61 jest suites / 990 tests pass; tsc --noEmit clean;
npm run lint clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /my-program renders "Unlimited — no cap" instead of overage label when cap is null - Checkout shows ₹0 with "Session value covered by enterprise license" for LICENSE bookings - Contract create form captures LICENSE flat fee + cycle; hides NET-X for LICENSE - /billing gets Annual License panel + LICENSE-aware empty state - /home Get Started marks billing configured when BillingSubscription is present - payment-terms validation skipped when LICENSE field hidden (Gemini review follow-up) Closes #755 Closes #756 Co-Authored-By: Shubham Kumar <shubham79a> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ date filter) - /home gets a "To reimburse (30d)" StatCard for fundingSource=PERSONAL orgs (MANAGER+), backed by a new payment.aggregate in /api/organizations/[orgId]/analytics - /reimbursements gets From/To date Inputs that flow into the existing API ?from/?to params (and the CSV export link) Closes #714 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ooking flow
buildPaymentMetadata() now accepts an optional orgContext arg ({ organizationId, fundingSource }); both fields are spread into Razorpay notes when non-null. Non-org flows unchanged.
Audit / fraud-verification trail (#687) — gateway record now carries the org claim alongside the booking; PR-3 reconciler can cross-check vs Payment.organizationId.
Part of #687
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h enforcement - lib/maintenance.ts: new getActiveOrgMaintenanceWindow(orgId) read helper - scripts/payouts/create-payout-batch.ts: skips individual tenants with active OFFLINE windows in the per-org loop - __tests__/enterprise/org-maintenance-window.test.ts: 4 unit tests pin the scoping contract Admin write API, per-org Redis keys, and Novu wiring deferred to a separate follow-up under #746. Part of #746 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment.organizationId Resolution chain now: explicit arg → plan.organizationId → appointment.organizationId → null. Closes the case where the plan is platform-owned but the booking is org-funded. createConsultationChannel/createSubscriptionChannel updated; DM channels intentionally remain untagged per scope. Part of #674 Part of #746 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l audit Adds the column + relation + index; writes the value at create time. Org call audit route indexes directly instead of joining through SlotOfAppointment. Migration 20260528_meeting_session_organization_id_denorm applied to Supabase. Part of #674 Part of #746 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BillingSubscription.renewalReminderSentAt — once-per-cycle gate - NOVU_WORKFLOWS.ORG_LICENSE_RENEWAL_UPCOMING + payload + notifier - generate-subscription-invoices.ts: 7-day-out scan at job entry, claim-then-trigger Migration 20260528_billing_subscription_renewal_reminder applied to Supabase. Part of #746 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New 36-cross-cutting-integrations.md is the single reference for which subsystems are enterprise-wired vs deliberately skipped, with code paths and rationale per row. Covers booking, money, membership, programs, Stream, compliance, operational, and the 25 dashboard routes plus an explicit SKIP list. Indexed from 00-overview.md row 36. Part of #746 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Enterprise foundation — full subsystem
This PR ships the complete enterprise subsystem on top of the existing
B2C marketplace: orgs, RBAC, SSO, billing/payouts, programs, audit, plus
the cross-cutting integrations (outbound webhooks, SCIM 2.0, DPDP §12
erasure, data export, retention crons, security headers, maintenance
tier-1, BILLING_ADMIN dashboard).
Production readiness: 100/100 per the May 2026 closeout.
CI: 957/957 jest,
tsc --noEmitclean, lint clean.Schema migrations: 4 applied to production via Supabase MCP
(
pr655_enterprise_lockdown_schema,pr655_schema_finalization_pre_mvp,pr655_maintenance_per_tenant_tier1).Defer list: #744 — Enterprise v1 post-MVP roadmap.
What ships in this PR
1. Organization lifecycle
Organizationmodel with the full capability matrix:canSponsor/canHost/INERT, funding modes(PERSONAL / WALLET / INVOICE / LICENSE), GST + MSME + PAN
compliance fields, hierarchy columns (
parentId / rootId / depth,UI deferred to Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744), 14 status + capability indexes.
/api/organizationscreates anOrganizationBillingAccount+OrgWorkspaceProfile+ member-OWNER membershipin one transaction.
PENDING_VERIFICATION → ACTIVE → SUSPENDED → DEACTIVATEDenforced viarequireOrgAccess+ adminverification routes.
/explore/enterprise/organisations.canHost: truehard-gated server-side behindENABLE_HOST_ORGS(rejects with typed
HOST_ORGS_GATED 400) so an org never ends uphalf-functional. Replaces the prior WIP banner — per reviewer
feedback "WIP banners are not production gates".
2. Membership + RBAC
MemberRoleenum:OWNER · MAINTAINER · BILLING_ADMIN · MANAGER · EXPERT · LEARNER · SUPPORT.BILLING_ADMINis the new finance-team role between MAINTAINER (rank 80) and MANAGER (rank 60) at
rank 70.
MemberStatusenum addsERASED(DPDP §12 tombstone).requireOrgAccess,requireOrgOwner,requireOrgBillingAdminOrOwner, and the page-leveluseRequireFinanceSurface/useRequireOperatorSurfacehooks.comparison) — MAINTAINER at rank 80 must still be denied billing
routes. Pinned by
__tests__/enterprise/billing-admin-gate.test.ts(9 cases).
billing-account PATCH, purchase-orders CRUD, invoices POST + PATCH
sso/**,domain-claims/**,members/**,invitations/**,scim/tokens/**.lib/enterprise/role-transitions.ts; LEARNER ↔ EXPERT disjointrule pinned by
__tests__/enterprise/member-anti-lockout.test.ts.sessionGenerationmarker bumped on every role change so thenext request through BetterAuth's
customSessionrefetcheswithout forcing a logout (audit Phase B.5).
3. SSO + auth
SsoProviderrows + theOrganizationSSOSettingsallowlist +OrgDomainClaimDNS-TXTverification.
defaultRoleForAutoJoin: LEARNER(principleof least privilege; rejects OWNER bootstrap).
lib/sso/provider-schemas.ts:validateSamlCertrunsnew X509Certificate(...)at registration AND at the pre-authdomain-checkendpoint. A bad legacy cert no longer crashesBetterAuth's SAML adapter with an empty-body 500 — the route
returns a typed
SSO_PROVIDER_MISCONFIGURED422 that the signinpage renders as a friendly toast.
4. Billing + invoicing
BillingAccountper sponsor org carrying the funding mode andper-currency wallet balance. Atomic
WalletEntry-based ledger.OrganizationInvoicewith GST-compliant breakdown (CGST/SGST/IGSTper place-of-supply), IRN placeholder + IRP submission cron,
per-org fiscal-year sequence allocation, PDF caching.
PurchaseOrder.remainingAmountPaisedecrement is now an atomicCAS via
updateManywithgtepredicate. Returns 409PO_BALANCE_EXCEEDEDon miss. VOID/CANCELLED transitions restorethe balance. 6-case regression test at
__tests__/enterprise/po-balance-enforcement.test.ts.invoice payment.
5. Programs + assignments
Programmodel withLICENSED_SEAT/CREDIT_POOL(shipped) andreserved
PROJECT/RETAINER/RESELLERfor V2 (schema-only,rejected at the API layer with
PROGRAM_TYPE_NOT_AVAILABLE 400—pinned by
__tests__/enterprise/programs-v2-rejection.test.ts).LicensedSeatConfigengagement caps + overage routing(BLOCK / CHARGE_MEMBER / CHARGE_ORG).
CreditPoolConfigcycle-based credit allocation.ProgramAssignmentper-period seat binding.BookingUtilizationper-payment engagement-consumed counter.RateCardwith time-scoped effective ranges + per-contract +per-membership overrides; basis-point split snapshot at booking.
6. Payouts + earnings
OrganizationPayoutAccount+ Razorpay-X / Stripe Connect linking.OrganizationEarningsper-payment org-share rows with hold +release state machine.
OrganizationPayoutweekly batch cron + manual admin route.MSME 15/45-day deadline alerts, RBI purpose code, DTAA rate,
clawback support, idempotent submission.
7. Audit + compliance
OrgAuditLogwith 11-category enum (now includingWEBHOOK).157 well-known action constants in
lib/enterprise/audit-actions.ts. Every mutation writes a row.(
scripts/cleanup/prune-audit-logs.ts, daily 03:15 UTC):7 years for INVOICE / PAYOUT / WALLET / CONTRACT / CONSENT (IT Act
§44AA); 2 years for MEMBER / SETTINGS / CATALOG / SYSTEM / PROGRAM
/ WEBHOOK. Emits one
AUDIT_PRUNEDsummary row per org per run.custody for compliance reviews).
8. Outbound webhooks (new subsystem)
External integrators (HRIS, finance ERP, customer-success tools) can
register HTTPS endpoints and subscribe to 8 lifecycle events:
member.added,member.removed,invoice.issued,invoice.paid,payout.completed,payout.failed,contract.signed,program.assigned.t=<unix>,v1=<hex>header, 9-hour replay window.Constant-time verifier (
crypto.timingSafeEqual).(1m → 5m → 30m → 2h → 8h then FAILED).
Permanent 4xx (excl. 408/429) skip retry; 5xx/408/429/network →
retry. Operator-paused endpoints short-circuit to FAILED.
redeliver). One-time secret reveal on POST; redacted everywhere
else.
transaction so a rollback also rolls back the delivery
(member-added that didn't commit doesn't emit).
.github/workflows/dispatch-outbound-webhooks.yml(every minute).
dispatch fan-out, worker retry schedule, operator-pause
short-circuit.
WebhookEndpoint+OutboundWebhookDelivery+WebhookEndpointStatus+DeliveryStatusenums. PlusWebhookEndpoint.secretRotatedAt+previousSecretHashcolumnsfor the 24h grace-window scaffolding (verifier upgrade is post-MVP).
docs/enterprise/29-outbound-webhooks.md.9. SCIM 2.0 (new subsystem)
Spec-compliant subset mounted at
/scim/v2/Usersso off-the-shelfIdP connectors (Okta, Azure AD, OneLogin, JumpCloud) work without
custom configuration.
/api/(IdPs hard-code the/scim/v2/prefix) with capital-cased segments per RFC 7644 §3.2.persisted as SHA-256 hash. Misuse on a revoked token writes a
SCIM_TOKEN_USED_AFTER_REVOKEaudit row.PATCH
replace activefor Okta/Azure deactivate, DELETE →SUSPEND (never erase — erasure is the user's own DPDP §12 right).
ScimGroupMappingrows; highest-rank role wins; LEARNER default for unmapped users.
a User returns
410 GonewhenUser.erasedAt IS NOT NULL.member.added/member.removedoutbound webhook withsource: "scim".ScimToken(+expiresAtfor auto-rotation TTL) +ScimGroupMapping+Membership.externalScimIdpartial-uniquescoped by orgId.
docs/enterprise/31-scim-provisioning.md.10. DPDP §12 automated erasure (new subsystem)
Pre-PR this was a manual CA + DB-admin process; now an admin can
fire-and-confirm in seconds.
POST /api/users/me/erasure-requests(idempotent — open requests dedup); admin reviews via
GET /api/admin/erasure-requests; processes via/process(runsscrubUserin a transaction) or rejects via/rejectwith a required reason.lib/compliance/erasure/scrub-user.ts) — one atomictransaction:
Erased User <hash>,erased-<hash>@erased.invalid, NULL elsewhere);erasedAtset;pseudonymousId = sha256(userId + salt).Membership.status → ERASED.ConsultantProfile.headline + videoIntroUrlscrubbed.Session+Accountrows hard-deleted (forcesimmediate sign-out across every device).
member.removedwebhook fan-out per affected org withsource: "dpdp_erasure"and pseudonymous payload.Payment*,OrganizationInvoice,OrganizationPayout,WalletEntry,FundingLedgerEntry,SettlementLedgerEntry,Refund.USER_ERASURE_REQUESTED / PROCESSED / REJECTED / SLA_WARNING. The audit row outlives the user's identity as theregulatory evidence-of-erasure record.
ErasureRequest(+processedByAdminId @relationFKadded in finalization). Partial-unique index ensures at-most-one
open request per user.
docs/enterprise/26-deletion-policy.md(Phase 2 rewrite).Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 C1.
11. DPDP §11 data export (new surface)
OWNER + BILLING_ADMIN can pull a JSON bundle of every org-scoped
entity: members, memberships, contracts, programs, invoices,
earnings, payouts, audit log.
/api/organizations/[orgId]/data-exports—orgDataExportLimiter(1/24h per org). Async worker
(
scripts/cleanup/process-data-exports.ts, every 10 min) builds thebundle, uploads to Supabase Storage with 7-day signed URL, emails
the requester via Resend.
schemaVersion: 1.OrgDataExportJob+OrgDataExportStatusenum.docs/enterprise/33-data-export.md.12. Stream call/recording surfaces
GET /api/organizations/[orgId]/stream/calls(MANAGER+) —paginated, joins
Recordinglazily on?withRecordings=1.Organization.streamRecordingRetentionDays(default 90).scripts/cleanup/cleanup-old-stream-recordings.tsdaily crontombstones recordings past the per-org window.
13. Maintenance subsystem — Tier 1 multi-tenant
Pre-PR maintenance was a platform-wide on/off switch. Tier 1
unlocks the per-tenant features without forcing them in now.
MaintenanceWindow.organizationIdnullable FK(NULL = platform-wide, legacy behaviour preserved; non-null =
scoped to that tenant).
lib/maintenance-edge.tsexportsplatformMaintenanceKeys()+orgMaintenanceKeys(orgId)so the post-MVP admin route can writeorg-scoped state under a canonical Redis namespace from day one.
workflows) tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 D3.
14. Security headers
next.config.mjsextended:Strict-Transport-Security—max-age=63072000; includeSubDomains; preload(2y).Content-Security-Policy-Report-Only— allow-list coversStream / Razorpay / Sentry / Supabase / Resend / Upstash;
media-srcincludesblob:+*.getstream.iofor call playback;frame-srcallows Razorpay checkout iframe.ENABLE_CSP_ENFORCE=trueflips to enforcing variant.
X-DNS-Prefetch-Control, Referrer-Policy, Permissions-Policy)
preserved.
/api/csp-reportsink (unauthenticated,spamLimiter-throttled)emits structured
event: "csp_violation"logs.docs/enterprise/23-runbooks.md.docs/enterprise/32-security-headers.md.15. BILLING_ADMIN dashboard surface
/integrations/route group:webhooks+scim+data-exportspages.Gated by
useRequireFinanceSurface(OWNER + MAINTAINER +BILLING_ADMIN + MANAGER).
FinanceLeadViewCardon/home— renders for BILLING_ADMINahead of the operator branch. Pulls outstanding invoices + wallet
balance from the existing analytics endpoint; Payouts + POs are
deep-link CTAs (analytics breakdowns tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744).
isAtLeast("MANAGER")checks withthe explicit
canSeeOperatorSurface/canSeeFinanceSurfacepredicates. Fixes a real bug where BILLING_ADMIN (rank 70 >
MANAGER 60) was silently granted access to
Members / Invitations / Audit / Settings.
16. Org-scope filter coverage (#674 follow-up — 6 leaks closed)
The #674 personal-vs-org scope split shipped
resolveOrgScope+denormalized
organizationIdcolumns on every leaf table, but thelegacy routes never got the filter. Fixed:
/api/slots/appointments—getAppointmentsnow takes anorgScopeFilterarg; consultant + consultee appointment tabs nolonger cross-tenant.
/api/dashboard/consultee/[id]/payments—Payment.organizationIdfilter; invoice subquery flows the same filter through the payment
join.
/api/trials—TrialSession.organizationIdfilter./api/waitlist—Waitlist.organizationIdfilter viagetUserWaitlistEntriesorgFilterarg./api/referrals— DELIBERATELY personal-only (referral codesfollow the user across orgs); doc-only record of intent.
components/chat/ChatSidebar.tsx— StreamqueryChannelsnowscopes on
custom.organization_id. Both initial fetch + paginatedload-more.
scopeadded touseCallbackdeps so the inboxrefetches on org-context toggle. Was the highest-impact leak —
a consultant in Acme + Zeta was seeing every chat cross-tenanted
in one inbox.
Conflict resolution remains intra-user, not inter-org by design.
SlotOfAvailability + SlotOfAppointment stay per-user — a consultant
has one body, one calendar, one meeting URL. The dashboard filter is
for visibility + finance attribution; double-booking detection across
orgs was already correct and unchanged.
Schema additions
Five Supabase MCP migrations applied to production
(all additive; no backfill required):
pr655_enterprise_lockdown_schema— 6 new tables, 5 newenums, 3 enum extensions (
MemberRole += BILLING_ADMIN,MemberStatus += ERASED,OrgAuditCategory += WEBHOOK),User += erasedAt + pseudonymousId,Membership += externalScimId,Organization += streamRecordingRetentionDays.pr655_schema_finalization_pre_mvp—ErasureRequest.processedByAdminIdFK,WebhookEndpoint.secretRotatedAt + previousSecretHash,ScimToken.expiresAt+ partial index.pr655_maintenance_per_tenant_tier1—MaintenanceWindow.organizationIdFK + indexes.Test coverage
PR start). Net +74 new tests:
over-counter bug in the existing cron
npx tsc --noEmitclean.npm run lintclean.the entire SCIM lifecycle (list / filter / create / PATCH / DELETE),
data export pipeline (request → worker → bundle build → Supabase
upload → download URL), DPDP §12 erasure (queue → process →
membership flip → audit row → webhook fan-out), and all four
cleanup crons.
Closing the May 2026 audit
lib/sso/provider-schemas.ts+domain-checkpre-auth guardhumanizeOrgErrortabledotenv/configacross 5 standalone jobs/callsendpoint (wasprune-audit-logs.tsdaily cronWhat's deliberately deferred
See #744 — Enterprise v1 post-MVP roadmap.
18 buckets across 7 sections. Highlights:
not shipping. New issue if a customer asks.
full impl needs an earnings-only helper extraction.
docs/compliance/10-rls-design-memo.md. App-layer auth issufficient for v1; trigger is Realtime/Storage in client OR SOC 2
audit OR anon-key leak.
enforcement — schema columns ship, enforcement reads are
follow-ups.
CSV export companion + HRIS UI + HOST org settlement
— all schema-ready, implementation deferred.
Commits in this PR
Plus everything pre-
fb68386cthat already shipped the org lifecycle,RBAC base, SSO scaffolding, billing/payouts base, and audit subsystem.
Test plan
npm test— 957/957 greennpx tsc --noEmit— cleannpm run lint— cleantick → bundle download from Supabase Storage)
Membership.status = ERASED, scrubbedUser.email, audit rowwritten,
member.removedwebhook queued)CRON_SECRET(audit-prune, Stream retention, webhook dispatch,data exports)
curl -I /(toggle PERSONAL → org1 → org2 → org1 across every tab) is
deferred to pilot smoke — tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 B2.
🤖 Generated with Claude Code