From d893919d2b409e66432409717b91fa7c21788a7f Mon Sep 17 00:00:00 2001 From: teetangh Date: Fri, 10 Apr 2026 05:46:15 +0530 Subject: [PATCH 001/415] feat(schema): add enterprise organization layer (BUYER complete, PROVIDER feature-flagged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- prisma/schema.prisma | 640 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 560 insertions(+), 80 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c8e84394..fc7282ca7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,10 +62,10 @@ model User { supportTickets SupportTicket[] supportResponses SupportResponse[] - accounts Account[] // BetterAuth Accounts - sessions Session[] // BetterAuth Sessions - members Member[] // Organization memberships - invitationsSent Invitation[] @relation("InvitationsSent") + accounts Account[] // BetterAuth Accounts + sessions Session[] // BetterAuth Sessions + members Member[] // Organization memberships + invitationsSent Invitation[] @relation("InvitationsSent") // Staff Dashboard Relations reportsSubmitted ModerationReport[] @relation("ReportsSubmitted") @@ -383,8 +383,9 @@ model Organization { members Member[] invitations Invitation[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationProfile OrganizationProfile? @@map("organizations") } @@ -398,6 +399,10 @@ model Member { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Enterprise: typed sibling profile carrying the typed role enum and + // consultant/consultee profile FKs that BetterAuth's Member can't express. + organizationMemberProfile OrganizationMemberProfile? + createdAt DateTime @default(now()) @@unique([organizationId, userId]) @@ -421,6 +426,429 @@ model Invitation { @@map("invitations") } +//////////////////////////////////////////////////// ENTERPRISE / ORGANIZATION PROFILES //////////////////////////////////////////////////// +// +// This block adds the enterprise layer on top of the BetterAuth Organization/ +// Member/Invitation tables above. The core split: +// +// - BetterAuth tables (identity): name, slug, role-as-string, invitations +// - OrganizationProfile (enterprise): kind, billing mode, branding, rates, seats +// - OrganizationMemberProfile (typed roles): enum role + status + profile FKs +// +// PROVIDER (consultant agency) support is in the schema but gated by the +// ENABLE_PROVIDER_ORGS environment flag at the API/UI layer. See +// lib/feature-flags.ts and Issue #646. The PROVIDER-specific tables below are +// marked with FEATURE-FLAGGED comments — they exist on disk but are inert +// until the flag is flipped. + +model OrganizationProfile { + id String @id @default(uuid()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + // Discriminator: BUYER (schools/corporates), PROVIDER (consultant agencies, gated), HYBRID + kind OrganizationKind @default(BUYER) + status OrganizationStatus @default(PENDING_VERIFICATION) + // BUYER billing model — selected at org creation, immutable after first payment + billingMode OrganizationBillingMode @default(TAG_ONLY) + + // Org metadata + billingEmail String + description String? @db.Text + industry String? + sizeBucket OrgSizeBucket? + website String? + + // Branding + logo String? + bannerImage String? + primaryColor String? + secondaryColor String? + + // Revenue rates — FEATURE-FLAGGED: only consulted for PROVIDER/HYBRID kinds + // when ENABLE_PROVIDER_ORGS is true. BUYER payments use the standard + // platform fee unchanged. + platformCommissionRate Float @default(0.10) // 10% platform + orgRetainRate Float @default(0.05) // 5% org + consultantPayoutRate Float @default(0.85) // 85% consultant + // Validation: rates sum to 1.0 enforced at API layer (PATCH /api/organizations/[id]) + + // Buyer settings + seatsTotal Int? // null = unlimited (rare; usually a custom enterprise plan) + seatsUsed Int @default(0) + // INVOICED_MONTHLY: max outstanding invoice balance before new checkouts are blocked + orgInvoiceCreditLimit Int? // in paise; null = unlimited + // INVOICED_MONTHLY: payment terms (NET-7, NET-15, NET-30, etc.) + paymentTermsDays Int @default(30) + + // Policies + defaultCancellationPolicy String? @db.Text + defaultRefundPolicy String? @db.Text + // FEATURE-FLAGGED: only consulted for PROVIDER orgs (auto-approve consultants joining the agency) + autoApproveConsultants Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Sibling relations + members OrganizationMemberProfile[] + ssoSettings OrganizationSSOSettings? + invoices OrganizationInvoice[] + plans OrganizationPlan[] + creditPool OrgCreditPool? + creditPurchases OrgCreditPurchase[] + creditLedger OrgCreditLedger[] + payments Payment[] // back-relation for Payment.organizationProfile + + // FEATURE-FLAGGED: PROVIDER org back-relations + payoutAccount OrganizationPayoutAccount? + payouts OrganizationPayout[] + earnings OrganizationEarnings[] + + // Catalog plans owned by the org (back-relations) + consultationPlans ConsultationPlan[] + subscriptionPlans SubscriptionPlan[] + webinarPlans WebinarPlan[] + classPlans ClassPlan[] + + @@index([kind]) + @@index([status]) +} + +enum OrganizationKind { + BUYER // Schools, corporates, agencies buying for their employees/students + PROVIDER // Consultant agencies hosting multiple consultants (FEATURE-FLAGGED) + HYBRID // Both — covers university/training-firm scenarios (FEATURE-FLAGGED) +} + +enum OrganizationStatus { + PENDING_VERIFICATION + ACTIVE + SUSPENDED + DEACTIVATED +} + +enum OrganizationBillingMode { + TAG_ONLY // Learner pays at checkout; payment is tagged with org for reporting only + SEAT_PACK // Org pre-purchases credits; learner checkouts decrement the credit pool + INVOICED_MONTHLY // Learners book freely; org gets a NET-X invoice at month-end +} + +enum OrgSizeBucket { + SMALL_1_50 + MEDIUM_51_200 + LARGE_201_1000 + ENTERPRISE_1000_PLUS +} + +// Typed sibling for BetterAuth's Member table. +// BetterAuth's Member.role is a String; we mirror it here as a typed enum +// and add the consultant/consultee profile FKs that BetterAuth doesn't know about. +model OrganizationMemberProfile { + id String @id @default(uuid()) + memberId String @unique // FK to BetterAuth Member.id + member Member @relation(fields: [memberId], references: [id], onUpdate: Cascade, onDelete: Cascade) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + role OrgMemberRole + status OrgMemberStatus @default(ACTIVE) + + // Profile links (only one of these is set per member, based on role) + // ORG_LEARNER → consulteeProfileId set + // ORG_CONSULTANT (FEATURE-FLAGGED) → consultantProfileId set + // Other roles (OWNER/ADMIN/MANAGER/SUPPORT) → both null, just metadata + consultantProfileId String? @unique + consultantProfile ConsultantProfile? @relation("OrgMemberConsultant", fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + consulteeProfileId String? @unique + consulteeProfile ConsulteeProfile? @relation("OrgMemberConsultee", fields: [consulteeProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + + // FEATURE-FLAGGED: per-consultant rate override for PROVIDER orgs. + // null = use org's consultantPayoutRate. + customConsultantPayoutRate Float? + + // BUYER seat metadata + seatAssignedAt DateTime? // when an ORG_LEARNER was assigned a seat (for invoiced/seat-pack reporting) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) + @@index([role]) + @@index([status]) +} + +enum OrgMemberRole { + ORG_OWNER // Full control: billing, deletion, settings, members. Both kinds. + ORG_ADMIN // Members + plans + settings (no billing/deletion). Both kinds. + ORG_MANAGER // BUYER: team analytics + seat mgmt. PROVIDER: consultant earnings view. + ORG_CONSULTANT // PROVIDER only — provides services on behalf of the org. FEATURE-FLAGGED. + ORG_LEARNER // BUYER only — employee/student consuming sessions. + ORG_SUPPORT // Either — support staff with no billing access. FEATURE-FLAGGED for PROVIDER. +} + +enum OrgMemberStatus { + PENDING + ACTIVE + SUSPENDED + REMOVED +} + +// SSO settings — our policy fields. The actual SAML/OIDC provider config +// lives in BetterAuth's auto-generated `ssoProvider` table (one provider per +// row, linked to organizationId). +model OrganizationSSOSettings { + id String @id @default(uuid()) + organizationProfileId String @unique + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + // Comma-separated email domains (e.g. ["acme.com", "acme.edu"]) that this + // org claims. Used by the signin domain router in middleware.ts. + allowedEmailDomains String[] @default([]) + + // If true, users with an email matching allowedEmailDomains MUST sign in + // via the org's SSO provider. Personal Google/GitHub OAuth is rejected. + enforceSSO Boolean @default(false) + + // Default role assigned when a new user auto-joins via SSO domain match. + defaultRoleForAutoJoin OrgMemberRole @default(ORG_LEARNER) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// FEATURE-FLAGGED: PROVIDER org payout account (where the org's slice goes). +// Schema-only — no API/UI without ENABLE_PROVIDER_ORGS=true. See Issue #646. +model OrganizationPayoutAccount { + id String @id @default(uuid()) + organizationProfileId String @unique + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + // Bank details (encrypted at rest in production — base64 stub for now) + accountHolderName String + accountNumberEncrypted String + accountNumberLast4 String + bankName String + ifscCode String? + routingNumber String? + swiftCode String? + + // Gateway IDs + stripeConnectId String? @unique + razorpayContactId String? @unique + razorpayFundAccountId String? + + status OrgPayoutAccountStatus @default(PENDING_VERIFICATION) + verifiedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum OrgPayoutAccountStatus { + PENDING_VERIFICATION + VERIFIED + FAILED_VERIFICATION + SUSPENDED +} + +// FEATURE-FLAGGED: PROVIDER org payout history. Schema-only. +model OrganizationPayout { + id String @id @default(uuid()) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + amount Int // in paise + currency String @default("INR") + status PayoutStatus @default(PENDING) // reuses existing PayoutStatus enum + paymentGateway PaymentGateway + + // Period covered + periodStart DateTime + periodEnd DateTime + + // Breakdown + grossRevenue Int + platformFee Int + refunds Int @default(0) + netPayout Int + + // Gateway tracking + payoutReference String? + failureReason String? + processedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) + @@index([status]) + @@index([periodStart, periodEnd]) +} + +// FEATURE-FLAGGED: PROVIDER org earnings (org's slice of each payment). +// Parallel to ConsultantEarnings — kept separate so each party's payout +// status is tracked independently. Schema-only. +model OrganizationEarnings { + id String @id @default(uuid()) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + paymentId String @unique + payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + grossAmount Int // in paise — same as Payment.originalAmount + platformFee Int // platform's slice (10% by default) + orgShare Int // org's slice (5% by default) + refundedAmount Int @default(0) + + status EarningStatus @default(PENDING) // reuses existing EarningStatus enum + holdUntil DateTime + currency String @default("INR") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) + @@index([status]) +} + +// Org-level invoices. Used by: +// - INVOICED_MONTHLY billing mode (BUYER) — auto-generated monthly by cron +// - PROVIDER orgs (FEATURE-FLAGGED) — periodic settlement statements +// - Manual invoicing (ORG_OWNER) — ad-hoc charges +model OrganizationInvoice { + id String @id @default(uuid()) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + invoiceNumber String @unique + amount Int // in paise + currency String @default("INR") + status OrgInvoiceStatus @default(DRAFT) + + // Line items as JSON: [{ description, quantity, unitPrice, paymentId? }, ...] + items Json + + // GST compliance (Indian tax regulations) + taxAmount Int? + taxRate Float? + hsnCode String? @default("999293") // SAC code for educational services + gstin String? + + // Optional FK to a Payment if this invoice was paid via the standard checkout + paymentId String? @unique + payment Payment? @relation("OrgInvoicePayment", fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: SetNull) + + // INVOICED_MONTHLY billing mode metadata + billingCycleStart DateTime? + billingCycleEnd DateTime? + autoGenerated Boolean @default(false) // true = generated by monthly cron, false = manual + + // Status timestamps + dueDate DateTime? + paidAt DateTime? + pdfUrl String? // populated by Issue #438 invoice PDF rendering (deferred) + + // Back-relation: payments billed against this invoice (INVOICED_MONTHLY) + billedPayments Payment[] @relation("PaymentBillableToOrgInvoice") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) + @@index([status]) + @@index([dueDate]) + @@index([billingCycleStart, billingCycleEnd]) +} + +enum OrgInvoiceStatus { + DRAFT + SENT + PAID + OVERDUE + CANCELLED +} + +// Org-owned catalog plans. These are templates that the org curates for its +// members. Distinct from per-consultant plans (ConsultationPlan etc.) — when +// rendered to learners, the org's plans show up alongside or instead of the +// consultant's own plans, depending on settings. +model OrganizationPlan { + id String @id @default(uuid()) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + planType AppointmentsType // CONSULTATION | SUBSCRIPTION | WEBINAR | CLASS | TRIAL + title String + description String? @db.Text + price Int // in paise + priceCurrency String @default("INR") + + isActive Boolean @default(true) + config Json // type-specific JSON (durationInHours, callsPerWeek, maxParticipants, etc.) + + // Which consultants this plan is assigned to (for PROVIDER orgs that pool + // multiple consultants). Empty array = any org consultant can fulfill. + // FEATURE-FLAGGED for non-empty assignment. + assignedConsultantIds String[] @default([]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) + @@index([planType]) +} + +// SEAT_PACK billing — credit pool. Org owner pre-purchases credits via gateway, +// learners draw down on each booking. +model OrgCreditPool { + id String @id @default(uuid()) + organizationProfileId String @unique + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + balance Int @default(0) // in paise (1 paise = 1 credit unit, for simplicity) + totalPurchased Int @default(0) // lifetime total credits purchased + currency String @default("INR") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OrgCreditPurchase { + id String @id @default(uuid()) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + creditsPurchased Int // amount added to OrgCreditPool.balance + amountPaid Int // in paise (gateway charge) + currency String @default("INR") + paymentId String? @unique + payment Payment? @relation("OrgCreditPurchasePayment", fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: SetNull) + + expiresAt DateTime? // optional credit expiry (unused for MVP) + purchasedAt DateTime @default(now()) + + @@index([organizationProfileId]) +} + +// Immutable ledger of every credit grant/debit. One row per state change. +model OrgCreditLedger { + id String @id @default(uuid()) + organizationProfileId String + organizationProfile OrganizationProfile @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + delta Int // signed: + for purchase/refund, - for booking deduction + reason String // "purchase" | "booking" | "refund" | "adjustment" | etc. + paymentId String? // FK to the booking Payment for "booking"/"refund" rows + memberProfileId String? // who consumed (ORG_LEARNER), if applicable + balanceAfter Int // running balance after this entry + + createdAt DateTime @default(now()) + + @@index([organizationProfileId, createdAt]) +} + //////////////////////////////////////////////////// USER PROFILES and SLOTTING MECHANISM //////////////////////////////////////////////////// model ConsultantProfile { @@ -495,6 +923,11 @@ model ConsultantProfile { totalRevenue Int @default(0) pendingRevenue Int @default(0) + // Enterprise: optional org membership (FEATURE-FLAGGED for PROVIDER orgs). + // null = independent consultant (most B2C consultants). + organizationMemberProfile OrganizationMemberProfile? @relation("OrgMemberConsultant") + isIndependent Boolean @default(true) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -561,10 +994,10 @@ model ConsultantReview { } model ConsulteeProfile { - id String @id @default(uuid()) - aboutMe String? @db.Text - preferredLanguage String? - goals String? @db.Text + id String @id @default(uuid()) + aboutMe String? @db.Text + preferredLanguage String? + goals String? @db.Text careerStage CareerStage? skillsToDevelop String[] @default([]) @@ -580,6 +1013,11 @@ model ConsulteeProfile { user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) userId String @unique + // Enterprise: optional org membership for ORG_LEARNERs (BUYER orgs). + // null = independent learner (most B2C consultees). + organizationMemberProfile OrganizationMemberProfile? @relation("OrgMemberConsultee") + isIndependent Boolean @default(true) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -761,11 +1199,17 @@ model ConsultationPlan { consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String + // Enterprise: optional org ownership for catalog plans curated at the org level + organizationProfile OrganizationProfile? @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + organizationProfileId String? + consultations Consultation[] materials PlanMaterial[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) } model Consultation { @@ -829,6 +1273,10 @@ model SubscriptionPlan { consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String + // Enterprise: optional org ownership + organizationProfile OrganizationProfile? @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + organizationProfileId String? + subscriptions Subscription[] subscriptionContents SubscriptionContent[] trialSessions TrialSession[] @@ -836,6 +1284,8 @@ model SubscriptionPlan { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([organizationProfileId]) } model Subscription { @@ -985,34 +1435,40 @@ enum BookingSource { // Many-Many Webinar model WebinarPlan { - id String @id @default(cuid()) - title String - topics Topic[] @relation("TopicToWebinarPlan") - description String? @db.Text - price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) - priceCurrency String @default("INR") - certificateProvided Boolean @default(false) + id String @id @default(cuid()) + title String + topics Topic[] @relation("TopicToWebinarPlan") + description String? @db.Text + price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency String @default("INR") + certificateProvided Boolean @default(false) recordingEnabled Boolean @default(false) // Allow consultant to record sessions recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) durationInHours Float @default(1) // Duration in hours maxParticipants Int @default(100) - language String? @default("English") - level String? @default("Beginner") - prerequisites String? @default("None") - materialProvided String? @default("None") - learningOutcomes String[] @default([]) - imageUrl String? - - consultantProfile ConsultantProfile? @relation(fields: [consultantProfileId], references: [id]) + language String? @default("English") + level String? @default("Beginner") + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) + imageUrl String? + + consultantProfile ConsultantProfile? @relation(fields: [consultantProfileId], references: [id]) consultantProfileId String? - webinars Webinar[] - materials PlanMaterial[] - collaborators WebinarCollaborator[] + + // Enterprise: optional org ownership + organizationProfile OrganizationProfile? @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + organizationProfileId String? + + webinars Webinar[] + materials PlanMaterial[] + collaborators WebinarCollaborator[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([consultantProfileId]) + @@index([organizationProfileId]) } model Webinar { @@ -1042,40 +1498,46 @@ enum WebinarStatus { // Many-Many Class model ClassPlan { - id String @id @default(cuid()) + id String @id @default(cuid()) title String - description String @db.Text - topics Topic[] @relation("TopicToClassPlan") + description String @db.Text + topics Topic[] @relation("TopicToClassPlan") classContents ClassContent[] price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) - priceCurrency String @default("INR") + priceCurrency String @default("INR") certificateProvided Boolean @default(false) recordingEnabled Boolean @default(false) // Allow consultant to record sessions recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) durationInMonths Int @default(1) // Duration in months - meetingsPerWeek Int @default(1) - sessionDurationInHours Float @default(1.0) // Duration of each session in hours - totalSessions Int @default(4) // meetingsPerWeek × durationInMonths × 4 - totalHours Float @default(4.0) // totalSessions × sessionDurationInHours - emailSupport PlanEmailSupport @default(GENERAL) - maxParticipants Int @default(1) - language String? @default("English") - level String? @default("Beginner") - prerequisites String? @default("None") - materialProvided String? @default("None") - learningOutcomes String[] @default([]) + meetingsPerWeek Int @default(1) + sessionDurationInHours Float @default(1.0) // Duration of each session in hours + totalSessions Int @default(4) // meetingsPerWeek × durationInMonths × 4 + totalHours Float @default(4.0) // totalSessions × sessionDurationInHours + emailSupport PlanEmailSupport @default(GENERAL) + maxParticipants Int @default(1) + language String? @default("English") + level String? @default("Beginner") + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) imageUrl String? - consultantProfile ConsultantProfile? @relation(fields: [consultantProfileId], references: [id]) + consultantProfile ConsultantProfile? @relation(fields: [consultantProfileId], references: [id]) consultantProfileId String? - classes Class[] - materials PlanMaterial[] - collaborators ClassCollaborator[] + + // Enterprise: optional org ownership + organizationProfile OrganizationProfile? @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + organizationProfileId String? + + classes Class[] + materials PlanMaterial[] + collaborators ClassCollaborator[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([consultantProfileId]) + @@index([organizationProfileId]) } model Class { @@ -1426,7 +1888,7 @@ enum RecordingStorageType { // Recording storage policy for plans (determines where recordings are stored) enum RecordingStoragePolicy { - STREAM_ONLY // 2-week temporary storage (free tier) + STREAM_ONLY // 2-week temporary storage (free tier) SUPABASE_PERMANENT // Permanent storage (premium tier) } @@ -1514,10 +1976,10 @@ model Payment { isMockPayment Boolean @default(false) // For development: mock payments skip gateway calls // International payment tracking - buyerCountry String? // ISO 3166-1 alpha-2 code detected at checkout - isInternational Boolean @default(false) // Denormalized for query efficiency + buyerCountry String? // ISO 3166-1 alpha-2 code detected at checkout + isInternational Boolean @default(false) // Denormalized for query efficiency displayCurrencyAtCheckout String? @db.VarChar(3) // Currency code shown to the buyer at checkout - exchangeRateAtCheckout Float? // Snapshot of INR→displayCurrencyAtCheckout for audit + exchangeRateAtCheckout Float? // Snapshot of INR→displayCurrencyAtCheckout for audit user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) userId String @@ -1534,6 +1996,22 @@ model Payment { creditUsages ReferralCreditUsage[] invoice Invoice? + // Enterprise: optional org tagging. + // Set on payments made by an org member (BUYER mode TAG_ONLY/SEAT_PACK/INVOICED_MONTHLY). + // FEATURE-FLAGGED: PROVIDER orgs use the same tag but trigger 3-way split logic. + organizationProfile OrganizationProfile? @relation(fields: [organizationProfileId], references: [id], onUpdate: Cascade, onDelete: SetNull) + organizationProfileId String? + + // INVOICED_MONTHLY: rolled up into this org invoice when the cron runs. + // null = not yet billed (still in this month's pending charges). + billableToOrgInvoice OrganizationInvoice? @relation("PaymentBillableToOrgInvoice", fields: [billableToOrgInvoiceId], references: [id], onUpdate: Cascade, onDelete: SetNull) + billableToOrgInvoiceId String? + + // Back-relations for org settlement flows + organizationEarnings OrganizationEarnings? // FEATURE-FLAGGED: PROVIDER 3-way split + organizationInvoiceSettled OrganizationInvoice? @relation("OrgInvoicePayment") // back-relation for OrganizationInvoice.payment + orgCreditPurchase OrgCreditPurchase? @relation("OrgCreditPurchasePayment") // back-relation for OrgCreditPurchase.payment + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1545,6 +2023,8 @@ model Payment { @@index([userId]) @@index([appointmentId]) @@index([paymentStatus, createdAt]) + @@index([organizationProfileId]) + @@index([billableToOrgInvoiceId]) } enum PaymentGateway { @@ -1563,16 +2043,16 @@ enum PaymentStatus { } model Refund { - id String @id @default(uuid()) - amount Int // Amount refunded in smallest currency unit (e.g., cents for USD) - currency String - reason String? // Reason for refund - status RefundStatus - refundId String @unique // Gateway-specific refund ID (Stripe/Razorpay) - paymentGateway PaymentGateway + id String @id @default(uuid()) + amount Int // Amount refunded in smallest currency unit (e.g., cents for USD) + currency String + reason String? // Reason for refund + status RefundStatus + refundId String @unique // Gateway-specific refund ID (Stripe/Razorpay) + paymentGateway PaymentGateway metadata Json? // Additional gateway-specific metadata exchangeRateAtRefund Float? // INR→displayCurrency rate snapshot at refund time - displayCurrency String? @db.VarChar(3) // Currency the buyer originally saw + displayCurrency String? @db.VarChar(3) // Currency the buyer originally saw payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Cascade) paymentId String @@ -1710,9 +2190,9 @@ model Payout { batchId String? // TDS (Tax Deducted at Source) — Section 194J - tdsDeducted Int @default(0) // TDS amount deducted from this payout, in paise - netAmount Int? // Amount after TDS deduction (amount - tdsDeducted), sent to gateway - tdsRateApplied Float? // TDS rate reserved for this payout, used when creating the final audit record + tdsDeducted Int @default(0) // TDS amount deducted from this payout, in paise + netAmount Int? // Amount after TDS deduction (amount - tdsDeducted), sent to gateway + tdsRateApplied Float? // TDS rate reserved for this payout, used when creating the final audit record tdsFinancialYear String? // FY when TDS was calculated (e.g. "2026-27"). Persisted to avoid FY-boundary drift. // Processing @@ -1775,15 +2255,15 @@ model PayoutAccount { ////////////////////////////////////////// TAX & COMPLIANCE ////////////////////////////////////////// model ConsultantTaxInfo { - id String @id @default(cuid()) - consultantProfileId String @unique - panEncrypted Bytes? // AES-256-GCM encrypted PAN. Format: [12B IV][ciphertext][16B auth tag] - panLast4 String? @db.VarChar(4) // Cleartext last 4 chars for masked display - panVerified Boolean @default(false) + id String @id @default(cuid()) + consultantProfileId String @unique + panEncrypted Bytes? // AES-256-GCM encrypted PAN. Format: [12B IV][ciphertext][16B auth tag] + panLast4 String? @db.VarChar(4) // Cleartext last 4 chars for masked display + panVerified Boolean @default(false) gstin String? // GSTIN for registered consultants - gstinVerified Boolean @default(false) - country String @default("IN") // ISO 3166-1 alpha-2 - isIndianResident Boolean @default(true) + gstinVerified Boolean @default(false) + country String @default("IN") // ISO 3166-1 alpha-2 + isIndianResident Boolean @default(true) lutNumber String? // Letter of Undertaking for export zero-rating lutValidUntil DateTime? @@ -1796,22 +2276,22 @@ model ConsultantTaxInfo { } model TDSRecord { - id String @id @default(cuid()) - consultantProfileId String - financialYear String // "2026-27" format (Apr-Mar) - quarter Int // 1=Apr-Jun, 2=Jul-Sep, 3=Oct-Dec, 4=Jan-Mar + id String @id @default(cuid()) + consultantProfileId String + financialYear String // "2026-27" format (Apr-Mar) + quarter Int // 1=Apr-Jun, 2=Jul-Sep, 3=Oct-Dec, 4=Jan-Mar // Amounts in paise - cumulativeAmountCredited Int // FY cumulative of amounts credited/paid to consultant (sum of completed payouts) - tdsDeducted Int // TDS amount deducted this record - tdsRate Float // Rate at time of deduction (10 or 20) + cumulativeAmountCredited Int // FY cumulative of amounts credited/paid to consultant (sum of completed payouts) + tdsDeducted Int // TDS amount deducted this record + tdsRate Float // Rate at time of deduction (10 or 20) // Link to the payout that triggered this deduction payoutId String? earningsId String? // Reversal flag (for refund-triggered TDS reversals — negative tdsDeducted) - isReversal Boolean @default(false) + isReversal Boolean @default(false) // Filing status reportedInForm26Q Boolean @default(false) @@ -1878,8 +2358,8 @@ model DiscountCode { code String @unique description String discountType DiscountType - discountValue Int // For PERCENTAGE: whole number (10 = 10%). For FIXED_AMOUNT: in paise - currency String @default("INR") // Currency for FIXED_AMOUNT discounts + discountValue Int // For PERCENTAGE: whole number (10 = 10%). For FIXED_AMOUNT: in paise + currency String @default("INR") // Currency for FIXED_AMOUNT discounts isActive Boolean @default(true) expiresAt DateTime? maxUses Int? From 7e69c96a08ff9c971ea276dce2d4c6df4d9c4aa2 Mon Sep 17 00:00:00 2001 From: teetangh Date: Fri, 10 Apr 2026 06:00:47 +0530 Subject: [PATCH 002/415] feat(auth): upgrade better-auth to 1.6.2 + enable Organization & SSO plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/auth.ts | 56 +++- package-lock.json | 766 +++++++++++++++++++++++++++++++++---------- package.json | 4 +- prisma/schema.prisma | 118 +++++-- 4 files changed, 743 insertions(+), 201 deletions(-) diff --git a/lib/auth.ts b/lib/auth.ts index 707e3c193..58df8cd5d 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,7 +1,8 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { nextCookies } from "better-auth/next-js"; -import { customSession } from "better-auth/plugins"; +import { customSession, organization } from "better-auth/plugins"; +import { sso } from "@better-auth/sso"; import bcrypt from "bcrypt"; import prisma from "@/lib/prisma"; import { @@ -215,6 +216,21 @@ export const auth = betterAuth({ }, plugins: [ + // Enterprise: BetterAuth Organization plugin. + // Pre-MVP cap of 5 orgs per user. Default creator role is ORG_OWNER — + // mirrored at the typed sibling layer (OrganizationMemberProfile). + organization({ + organizationLimit: 5, + creatorRole: "ORG_OWNER", + }), + + // Enterprise: SSO plugin (SAML / OIDC). + // Auto-generates the `ssoProvider` table. Per-org providers are linked + // via `organizationId` on the row. See lib/auth-helpers.ts and the + // OrganizationSSOSettings model in prisma/schema.prisma for the policy + // layer (allowedEmailDomains, enforceSSO). + sso(), + customSession(async ({ user: baseUser, session }) => { // Cast to include additionalFields (available at runtime via BetterAuth, // but not reflected in the customSession callback's parameter type) @@ -229,6 +245,43 @@ export const auth = betterAuth({ staffProfileId?: string | null; adminProfileId?: string | null; }; + + // Enterprise: load the user's active org memberships so the OrgSwitcher + // and any org-aware route can read them from the session without an + // extra DB roundtrip. Cheap query — joined to the typed sibling profile + // for the role enum and the parent OrganizationProfile/Organization. + const memberships = await prisma.organizationMemberProfile.findMany({ + where: { + status: "ACTIVE", + member: { userId: user.id }, + }, + select: { + role: true, + organizationProfileId: true, + organizationProfile: { + select: { + kind: true, + status: true, + organization: { + select: { id: true, name: true, slug: true, logo: true }, + }, + }, + }, + }, + }); + + const organizationMemberships = memberships + .filter((m) => m.organizationProfile.status === "ACTIVE") + .map((m) => ({ + organizationId: m.organizationProfile.organization.id, + organizationName: m.organizationProfile.organization.name, + organizationSlug: m.organizationProfile.organization.slug, + organizationLogo: m.organizationProfile.organization.logo, + organizationProfileId: m.organizationProfileId, + kind: m.organizationProfile.kind, + role: m.role, + })); + return { user: { ...user, @@ -241,6 +294,7 @@ export const auth = betterAuth({ consulteeProfileId: user.consulteeProfileId ?? undefined, staffProfileId: user.staffProfileId ?? undefined, adminProfileId: user.adminProfileId ?? undefined, + organizationMemberships, }, session, }; diff --git a/package-lock.json b/package-lock.json index bf62e0706..28f483fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.2.0", "hasInstallScript": true, "dependencies": { + "@better-auth/sso": "^1.6.2", "@faker-js/faker": "^10.2.0", "@hookform/resolvers": "^5.2.2", "@novu/api": "^3.13.0", "@novu/nextjs": "^3.13.0", "@novu/react": "^3.13.0", + "@opentelemetry/api": "^1.9.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", "@radix-ui/react-accordion": "^1.2.12", @@ -53,7 +55,7 @@ "bad-words": "^4.0.0", "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", - "better-auth": "^1.4.18", + "better-auth": "^1.6.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -133,6 +135,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -162,6 +165,20 @@ "dev": true, "license": "ISC" }, + "node_modules/@authenio/xml-encryption": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@authenio/xml-encryption/-/xml-encryption-2.0.2.tgz", + "integrity": "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -687,11 +704,167 @@ "dev": true, "license": "MIT" }, + "node_modules/@better-auth/core": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.2.tgz", + "integrity": "sha512-nBftDp+eN1fwXor1O4KQorCXa0tJNDgpab7O1z4NcWUU+3faDpdzqLn5mbXZer2E8ZD4VhjqOfYZ041xnBF5NA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@better-auth/core/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.2.tgz", + "integrity": "sha512-KawrNNuhgmpcc5PgLs6HesMckxCscz5J+BQ99iRmU1cLzG/A87IcydrmYtep+K8WHPN0HmZ/i4z/nOBCtxE2qA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0", + "drizzle-orm": ">=0.41.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.2.tgz", + "integrity": "sha512-YMMm75jek/MNCAFWTAaq/U3VPmFnrwZW4NhBjjAwruHQJEIrSZZaOaUEXuUpFRRBhWqg7OOltQcHMwU/45CkuA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0", + "kysely": "^0.27.0 || ^0.28.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.2.tgz", + "integrity": "sha512-QvuK5m7NFgkzLPHyab+NORu3J683nj36Tix58qq6DPcniyY6KZk5gY2yyh4+z1wgSjrxwY5NFx/DC2qz8B8NJg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.2.tgz", + "integrity": "sha512-IvR2Q+1pjzxA4JXI3ED76+6fsqervIpZ2K5MxoX/+miLQhLEmNcbqqcItg4O2kfkxN8h33/ev57sjTW8QH9Tuw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.2.tgz", + "integrity": "sha512-bQkXYTo1zPau+xAiMpo1yCjEDSy7i7oeYlkYO+fSfRDCo52DE/9oPOOuI+EStmFkPUNSk9L2rhk8Fulifi8WCg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/sso": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/sso/-/sso-1.6.2.tgz", + "integrity": "sha512-qcaG/uKEWlPO2c+Gp0Gwv2NEgTM1+7kiz6PWGB9pGWzgnpZaZ04by05IsH6ocCE0mRY/M0Trv+K3tVCydXVXLQ==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.7", + "jose": "^6.1.3", + "samlify": "~2.10.2", + "tldts": "^6.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "better-auth": "^1.6.2", + "better-call": "1.3.5" + } + }, + "node_modules/@better-auth/sso/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.2.tgz", + "integrity": "sha512-o4gHKXqizUxVUUYChZZTowLEzdsz3ViBE/fKFzfHqNFUnF+aVt8QsbLSfipq1WpTIXyJVT/SnH0hgSdWxdssbQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.2", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21" + } + }, "node_modules/@better-auth/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", - "license": "MIT" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } }, "node_modules/@better-fetch/fetch": { "version": "1.1.21", @@ -708,7 +881,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/gast": "10.5.0", @@ -720,7 +893,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/types": "10.5.0", @@ -731,14 +904,14 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@corvu/utils": { @@ -912,14 +1085,14 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -932,7 +1105,7 @@ "version": "0.2.20", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.3.15" @@ -1201,7 +1374,7 @@ "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -2636,6 +2809,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2657,6 +2831,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2666,12 +2841,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2795,7 +2972,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chevrotain": "^10.5.0", @@ -2809,7 +2986,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3040,6 +3217,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -3053,6 +3231,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -3062,6 +3241,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -3147,6 +3327,24 @@ } } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3223,7 +3421,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.3.0.tgz", "integrity": "sha512-QyMV67+eXF7uMtKxTEeQqNu/Be7iH+3iDZOQZW5ttfbSwBamCSdwPszA0dum+Wx27I7anYTPLmRmMORKViSW1A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -3242,7 +3440,7 @@ "version": "0.20.0", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.3.15", @@ -3277,7 +3475,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.3.0.tgz", "integrity": "sha512-cWRQoPDXPtR6stOWuWFZf9pHdQ/o8/QNWn0m0zByxf5Kd946Q875XdEJ52pEsX88vOiXUmjuPG3euw82mwQNMg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3291,14 +3489,14 @@ "version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735.tgz", "integrity": "sha512-IH2va2ouUHihyiTTRW889LjKAl1CusZOvFfZxCDNpjSENt7g2ndFsK0vdIw/72v7+jCN6YgkHmdAP/BI7SDgyg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.3.0.tgz", "integrity": "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.3.0" @@ -3308,7 +3506,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.3.0.tgz", "integrity": "sha512-Mm0F84JMqM9Vxk70pzfNpGJ1lE4hYjOeLMu7nOOD1i83nvp8MSAcFYBnHqLvEZiA6onUR+m8iYogtOY4oPO5lQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.3.0", @@ -3320,7 +3518,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.3.0.tgz", "integrity": "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.3.0" @@ -3330,7 +3528,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -3340,21 +3538,21 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/studio-core": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", @@ -6220,12 +6418,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6236,7 +6436,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -6893,6 +7093,24 @@ "integrity": "sha512-iirJNv92A1ZWxoOHHDYW/1KPoi83939o83iUBQHIim0i3tMeSKEh+bxhJdTHQ86Mr4uXx9xGUTq69cp52ZP8Xw==", "license": "MIT" }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -7005,12 +7223,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -7024,6 +7244,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -7193,6 +7414,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7275,7 +7505,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -7513,23 +7743,28 @@ } }, "node_modules/better-auth": { - "version": "1.4.18", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.18.tgz", - "integrity": "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.4.18", - "@better-auth/telemetry": "1.4.18", - "@better-auth/utils": "0.3.0", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.2.tgz", + "integrity": "sha512-5nqDAIj5xexmnk+GjjdrBknJCabi1mlvsVWJbxs4usHreao4vNdxIxINWDzCyDF9iDR1ildRZdXWSiYPAvTHhA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.2", + "@better-auth/drizzle-adapter": "1.6.2", + "@better-auth/kysely-adapter": "1.6.2", + "@better-auth/memory-adapter": "1.6.2", + "@better-auth/mongo-adapter": "1.6.2", + "@better-auth/prisma-adapter": "1.6.2", + "@better-auth/telemetry": "1.6.2", + "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", - "@noble/ciphers": "^2.0.0", - "@noble/hashes": "^2.0.0", - "better-call": "1.1.8", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.5", "defu": "^6.1.4", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1", - "zod": "^4.3.5" + "jose": "^6.1.3", + "kysely": "^0.28.14", + "nanostores": "^1.1.1", + "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", @@ -7612,45 +7847,25 @@ } } }, - "node_modules/better-auth/node_modules/@better-auth/core": { - "version": "1.4.18", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", - "integrity": "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "zod": "^4.3.5" - }, - "peerDependencies": { - "@better-auth/utils": "0.3.0", - "@better-fetch/fetch": "1.1.21", - "better-call": "1.1.8", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1" - } - }, - "node_modules/better-auth/node_modules/@better-auth/telemetry": { - "version": "1.4.18", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.18.tgz", - "integrity": "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==", - "dependencies": { - "@better-auth/utils": "0.3.0", - "@better-fetch/fetch": "1.1.21" - }, - "peerDependencies": { - "@better-auth/core": "1.4.18" + "node_modules/better-auth/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/better-auth/node_modules/better-call": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", - "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", + "node_modules/better-call": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", + "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", "license": "MIT", "dependencies": { - "@better-auth/utils": "^0.3.0", - "@better-fetch/fetch": "^1.1.4", - "rou3": "^0.7.10", - "set-cookie-parser": "^2.7.1" + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" @@ -7661,24 +7876,6 @@ } } }, - "node_modules/better-auth/node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/better-auth/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -7692,6 +7889,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7715,6 +7913,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -7802,7 +8001,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -7831,7 +8030,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -7912,6 +8111,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8026,7 +8226,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", @@ -8041,7 +8241,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -8073,7 +8273,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -8233,14 +8433,14 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -8264,7 +8464,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8292,6 +8492,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -8625,7 +8826,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -8686,7 +8887,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -8705,7 +8906,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -8756,6 +8957,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/diff": { @@ -8772,6 +8974,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -8957,7 +9160,7 @@ "version": "3.18.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -9000,7 +9203,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -9271,6 +9474,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -9677,7 +9886,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/extend": { @@ -9690,7 +9899,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -9713,7 +9922,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -9745,6 +9954,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -9761,6 +9971,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -9789,10 +10000,46 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", + "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.4.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -9837,6 +10084,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -9946,7 +10194,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -9963,7 +10211,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -10040,6 +10288,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10094,7 +10343,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -10177,7 +10426,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/get-proto": { @@ -10228,7 +10477,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -10264,6 +10513,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -10334,21 +10584,21 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.0.tgz", "integrity": "sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA==", - "devOptional": true + "dev": true }, "node_modules/gzip-size": { "version": "6.0.0", @@ -10565,7 +10815,7 @@ "version": "4.11.4", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -10669,7 +10919,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/https-proxy-agent": { @@ -10959,6 +11209,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -11001,6 +11252,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -11061,6 +11313,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11126,6 +11379,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -11174,6 +11428,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -11229,7 +11484,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-regex": { @@ -11407,7 +11662,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/iso-639-1": { @@ -12768,12 +13023,21 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -12966,9 +13230,9 @@ } }, "node_modules/kysely": { - "version": "0.28.11", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", - "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "version": "0.28.15", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.15.tgz", + "integrity": "sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==", "license": "MIT", "engines": { "node": ">=20.0.0" @@ -13026,6 +13290,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -13057,6 +13322,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/linkifyjs": { @@ -13183,7 +13449,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/longest-streak": { @@ -13222,7 +13488,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -13612,6 +13878,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -14184,6 +14451,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -14308,7 +14576,7 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -14329,7 +14597,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -14346,6 +14614,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -14357,7 +14626,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -14385,9 +14654,9 @@ } }, "node_modules/nanostores": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", - "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", + "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", "funding": [ { "type": "github", @@ -14524,9 +14793,18 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -14552,10 +14830,20 @@ "dev": true, "license": "MIT" }, + "node_modules/node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "license": "MIT", + "dependencies": { + "asn1": "^0.2.4" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16540,7 +16828,7 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz", "integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.0", @@ -16558,7 +16846,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz", "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/object-assign": { @@ -16671,7 +16959,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/once": { @@ -16922,6 +17210,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz", + "integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -16936,7 +17239,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16946,6 +17249,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -16979,7 +17283,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/peberminta": { @@ -16995,7 +17299,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pg": { @@ -17106,6 +17410,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -17118,6 +17423,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17127,6 +17433,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -17205,7 +17512,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -17227,6 +17534,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -17255,6 +17563,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -17272,6 +17581,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -17292,6 +17602,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -17317,6 +17628,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, "funding": [ { "type": "opencollective", @@ -17359,6 +17671,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -17384,6 +17697,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -17403,7 +17717,7 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, + "dev": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -17496,7 +17810,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz", "integrity": "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -17556,7 +17870,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -17635,6 +17949,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -17664,7 +17979,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -17987,6 +18302,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -17996,7 +18312,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -18079,7 +18395,7 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/regexp.prototype.flags": { @@ -18173,7 +18489,7 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -18279,7 +18595,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -18289,6 +18605,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -18312,6 +18629,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -18419,9 +18737,48 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, + "node_modules/samlify": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/samlify/-/samlify-2.10.2.tgz", + "integrity": "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA==", + "license": "MIT", + "dependencies": { + "@authenio/xml-encryption": "^2.0.2", + "@xmldom/xmldom": "^0.8.6", + "camelcase": "^6.2.0", + "node-forge": "^1.3.0", + "node-rsa": "^1.1.1", + "pako": "^1.0.10", + "uuid": "^8.3.2", + "xml": "^1.0.1", + "xml-crypto": "^6.1.2", + "xml-escape": "^1.1.0", + "xpath": "^0.0.32" + } + }, + "node_modules/samlify/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/samlify/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -18487,7 +18844,7 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true + "dev": true }, "node_modules/seroval": { "version": "1.5.0", @@ -18517,9 +18874,9 @@ "license": "MIT" }, "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, "node_modules/set-function-length": { @@ -18619,7 +18976,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -18632,7 +18989,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -18714,7 +19071,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/simple-swizzle": { @@ -18927,7 +19284,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -18970,7 +19327,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -19342,6 +19699,18 @@ } } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -19387,6 +19756,7 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -19409,6 +19779,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -19431,6 +19802,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -19524,6 +19896,7 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -19570,6 +19943,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -19594,6 +19968,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -19606,6 +19981,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -19615,6 +19991,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -19624,6 +20001,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -19636,6 +20014,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -19693,6 +20072,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -19702,6 +20082,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -19726,7 +20107,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -19736,6 +20117,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -19752,6 +20134,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -19769,6 +20152,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -19781,7 +20165,6 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^6.1.86" @@ -19794,7 +20177,6 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, "license": "MIT" }, "node_modules/tmpl": { @@ -19808,6 +20190,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -19889,6 +20272,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/ts-node": { @@ -20078,7 +20462,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -20525,7 +20909,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -20754,7 +21138,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -20989,6 +21373,41 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml-escape": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", + "integrity": "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==", + "license": "MIT License" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -21014,6 +21433,15 @@ "node": ">=0.4.0" } }, + "node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -21102,7 +21530,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", diff --git a/package.json b/package.json index c8d005f10..378712d7a 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,9 @@ "bad-words": "^4.0.0", "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", - "better-auth": "^1.4.18", + "better-auth": "^1.6.2", + "@better-auth/sso": "^1.6.2", + "@opentelemetry/api": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc7282ca7..9e6186dec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,9 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + invitations Invitation[] + ssoproviders SsoProvider[] + @@index([consultantProfileId]) @@index([consulteeProfileId]) @@index([adminProfileId]) @@ -199,6 +202,7 @@ enum SupportPriority { } // Cancellation reasons for Consultations and Subscriptions + enum CancellationReason { // User-initiated SCHEDULE_CONFLICT @@ -224,6 +228,7 @@ enum CancellationReason { } // Issue types for support tickets (Swiggy-style categorization) + enum SupportIssueType { // Session Issues CONSULTANT_NO_SHOW @@ -357,6 +362,8 @@ model Session { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + activeOrganizationId String? + @@index([userId]) @@map("sessions") } @@ -370,6 +377,7 @@ model Verification { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([identifier]) @@map("verifications") } @@ -406,6 +414,8 @@ model Member { createdAt DateTime @default(now()) @@unique([organizationId, userId]) + @@index([organizationId]) + @@index([userId]) @@map("members") } @@ -422,7 +432,11 @@ model Invitation { inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) + userId String? + @@index([organizationId]) + @@index([email]) @@map("invitations") } @@ -544,6 +558,7 @@ enum OrgSizeBucket { // Typed sibling for BetterAuth's Member table. // BetterAuth's Member.role is a String; we mirror it here as a typed enum // and add the consultant/consultee profile FKs that BetterAuth doesn't know about. + model OrganizationMemberProfile { id String @id @default(uuid()) memberId String @unique // FK to BetterAuth Member.id @@ -597,6 +612,7 @@ enum OrgMemberStatus { // SSO settings — our policy fields. The actual SAML/OIDC provider config // lives in BetterAuth's auto-generated `ssoProvider` table (one provider per // row, linked to organizationId). + model OrganizationSSOSettings { id String @id @default(uuid()) organizationProfileId String @unique @@ -619,6 +635,7 @@ model OrganizationSSOSettings { // FEATURE-FLAGGED: PROVIDER org payout account (where the org's slice goes). // Schema-only — no API/UI without ENABLE_PROVIDER_ORGS=true. See Issue #646. + model OrganizationPayoutAccount { id String @id @default(uuid()) organizationProfileId String @unique @@ -653,6 +670,7 @@ enum OrgPayoutAccountStatus { } // FEATURE-FLAGGED: PROVIDER org payout history. Schema-only. + model OrganizationPayout { id String @id @default(uuid()) organizationProfileId String @@ -689,6 +707,7 @@ model OrganizationPayout { // FEATURE-FLAGGED: PROVIDER org earnings (org's slice of each payment). // Parallel to ConsultantEarnings — kept separate so each party's payout // status is tracked independently. Schema-only. + model OrganizationEarnings { id String @id @default(uuid()) organizationProfileId String @@ -717,6 +736,7 @@ model OrganizationEarnings { // - INVOICED_MONTHLY billing mode (BUYER) — auto-generated monthly by cron // - PROVIDER orgs (FEATURE-FLAGGED) — periodic settlement statements // - Manual invoicing (ORG_OWNER) — ad-hoc charges + model OrganizationInvoice { id String @id @default(uuid()) organizationProfileId String @@ -774,6 +794,7 @@ enum OrgInvoiceStatus { // members. Distinct from per-consultant plans (ConsultationPlan etc.) — when // rendered to learners, the org's plans show up alongside or instead of the // consultant's own plans, depending on settings. + model OrganizationPlan { id String @id @default(uuid()) organizationProfileId String @@ -802,6 +823,7 @@ model OrganizationPlan { // SEAT_PACK billing — credit pool. Org owner pre-purchases credits via gateway, // learners draw down on each booking. + model OrgCreditPool { id String @id @default(uuid()) organizationProfileId String @unique @@ -833,6 +855,7 @@ model OrgCreditPurchase { } // Immutable ledger of every credit grant/debit. One row per state change. + model OrgCreditLedger { id String @id @default(uuid()) organizationProfileId String @@ -1159,22 +1182,22 @@ model SlotOfAvailabilityWeekly { consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([consultantProfileId]) } model SlotOfAvailabilityCustom { id String @id @default(uuid()) - startsAt DateTime @db.Timestamptz() - endsAt DateTime @db.Timestamptz() + startsAt DateTime @db.Timestamptz + endsAt DateTime @db.Timestamptz consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([consultantProfileId]) } @@ -1182,6 +1205,7 @@ model SlotOfAvailabilityCustom { //////////////////////////////////////////////////// PRICING PLANS //////////////////////////////////////////////////// // 1-1 Consultation + model ConsultationPlan { id String @id @default(cuid()) title String @@ -1220,7 +1244,7 @@ model Consultation { requestStatus RequestStatus @default(PENDING) requestedBy ConsulteeProfile @relation(fields: [requestedById], references: [id], onUpdate: Cascade, onDelete: Cascade) requestedById String - requestedAt DateTime @default(now()) @db.Timestamptz() + requestedAt DateTime @default(now()) @db.Timestamptz requestNotes String? pendingPaymentUrl String? // Payment link while awaiting payment (cleared after payment) bookingSource BookingSource @default(REQUEST_SUBMITTED) @@ -1231,13 +1255,13 @@ model Consultation { // Cancellation tracking cancellationReason CancellationReason? cancellationNotes String? @db.Text - cancelledAt DateTime? @db.Timestamptz() + cancelledAt DateTime? @db.Timestamptz cancelledBy String? // userId who cancelled appointment Appointment? - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([requestedById]) @@index([consultationPlanId]) @@ -1290,14 +1314,14 @@ model SubscriptionPlan { model Subscription { id String @id @default(cuid()) - schedulingPeriodStartsAt DateTime @db.Timestamptz() - schedulingPeriodEndsAt DateTime @db.Timestamptz() + schedulingPeriodStartsAt DateTime @db.Timestamptz + schedulingPeriodEndsAt DateTime @db.Timestamptz schedulingTimezone String @default("Asia/Kolkata") requestStatus RequestStatus @default(PENDING) requestedBy ConsulteeProfile @relation(fields: [requestedById], references: [id], onUpdate: Cascade, onDelete: Cascade) requestedById String - requestedAt DateTime @default(now()) @db.Timestamptz() + requestedAt DateTime @default(now()) @db.Timestamptz requestNotes String? pendingPaymentUrl String? // Payment link while awaiting payment (cleared after payment) bookingSource BookingSource @default(REQUEST_SUBMITTED) @@ -1308,7 +1332,7 @@ model Subscription { // Cancellation tracking cancellationReason CancellationReason? cancellationNotes String? @db.Text - cancelledAt DateTime? @db.Timestamptz() + cancelledAt DateTime? @db.Timestamptz cancelledBy String? // userId who cancelled subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) @@ -1317,8 +1341,8 @@ model Subscription { appointments Appointment[] convertedFromTrial TrialSession? - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([requestedById]) @@index([subscriptionPlanId]) @@ -1340,6 +1364,7 @@ enum RequestStatus { } // Free trial session tracking for subscriptions + model TrialSession { id String @id @default(cuid()) status TrialSessionStatus @default(PENDING) @@ -1369,7 +1394,8 @@ model TrialSession { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([consulteeProfileId, consultantProfileId]) // One trial per consultant + // One trial per consultant + @@unique([consulteeProfileId, consultantProfileId]) @@index([consultantProfileId]) @@index([subscriptionPlanId]) } @@ -1384,6 +1410,7 @@ enum TrialSessionStatus { } // Activity tracking for consultant dashboard + model ActivityLog { id String @id @default(cuid()) activityType ActivityType @@ -1434,6 +1461,7 @@ enum BookingSource { } // Many-Many Webinar + model WebinarPlan { id String @id @default(cuid()) title String @@ -1497,6 +1525,7 @@ enum WebinarStatus { } // Many-Many Class + model ClassPlan { id String @id @default(cuid()) title String @@ -1542,8 +1571,8 @@ model ClassPlan { model Class { id String @id @default(cuid()) - schedulingPeriodStartsAt DateTime? @db.Timestamptz() - schedulingPeriodEndsAt DateTime? @db.Timestamptz() + schedulingPeriodStartsAt DateTime? @db.Timestamptz + schedulingPeriodEndsAt DateTime? @db.Timestamptz schedulingTimezone String @default("Asia/Kolkata") status ClassStatus @default(SCHEDULED) waitlist Waitlist[] @@ -1555,8 +1584,8 @@ model Class { appointments Appointment[] - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([classPlanId]) @@index([status]) @@ -1582,6 +1611,7 @@ model ClassContent { } // Session-by-session curriculum for subscriptions (similar to ClassContent) + model SubscriptionContent { id String @id @default(cuid()) title String @@ -1633,6 +1663,7 @@ model Newsletter { } // Waitlist status for tracking user position and notification state + enum WaitlistStatus { WAITING // In queue, waiting for spot NOTIFIED // Spot available, awaiting user response @@ -1688,6 +1719,7 @@ model Waitlist { ////////////////////////////////////////////// APPOINTMENT //////////////////////////////////////////////////// // Generic Appointment Model + model Appointment { id String @id @default(uuid()) appointmentType AppointmentsType @@ -1710,8 +1742,8 @@ model Appointment { payment Payment[] documents AppointmentDocument[] - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([subscriptionId]) @@index([classId]) @@ -1729,6 +1761,7 @@ enum AppointmentsType { } // Document Review Models + model AppointmentDocument { id String @id @default(uuid()) fileName String @@ -1781,6 +1814,7 @@ enum DocumentUploadRole { } // Plan Materials - Consultant-uploaded materials at plan level (shared across all instances) + model PlanMaterial { id String @id @default(uuid()) fileName String @@ -1819,24 +1853,24 @@ model SlotOfAppointment { user User[] @relation("SlotOfAppointmentToUser") - startsAt DateTime @db.Timestamptz() - endsAt DateTime @db.Timestamptz() + startsAt DateTime @db.Timestamptz + endsAt DateTime @db.Timestamptz isTentative Boolean @default(false) completionStatus SlotCompletionStatus @default(SCHEDULED) - completedAt DateTime? @db.Timestamptz() + completedAt DateTime? @db.Timestamptz appointment Appointment @relation(fields: [appointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) appointmentId String meetingSession MeetingSession? - createdAt DateTime @default(now()) @db.Timestamptz() - updatedAt DateTime @updatedAt @db.Timestamptz() + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz + // OPT-3: Additional indexes for time range queries in validateSlotAvailability @@index([appointmentId]) @@index([isTentative, appointmentId]) - // OPT-3: Additional indexes for time range queries in validateSlotAvailability @@index([startsAt, endsAt]) @@index([isTentative, startsAt, endsAt]) @@index([createdAt]) @@ -1881,18 +1915,21 @@ model MeetingSession { } // Recording storage types + enum RecordingStorageType { STREAM_S3 // Default: expires in 2 weeks SUPABASE // Permanent storage } // Recording storage policy for plans (determines where recordings are stored) + enum RecordingStoragePolicy { STREAM_ONLY // 2-week temporary storage (free tier) SUPABASE_PERMANENT // Permanent storage (premium tier) } // Recording status lifecycle + enum RecordingStatus { RECORDING // Currently being recorded PROCESSING // Stream is processing the recording @@ -1904,6 +1941,7 @@ enum RecordingStatus { } // Model for storing recording details with dual storage support + model Recording { id String @id @default(cuid()) title String @@ -2015,7 +2053,8 @@ model Payment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([userId, appointmentId]) // Allow multiple users to pay for same appointment (webinars/classes) + // Allow multiple users to pay for same appointment (webinars/classes) + @@unique([userId, appointmentId]) @@index([expiresAt, paymentStatus]) @@index([isMockPayment]) @@index([paymentStatus]) @@ -2633,6 +2672,7 @@ enum SessionType { //////////////////////////////////////////////////// STAFF DASHBOARD MODELS //////////////////////////////////////////////////// // Content Moderation Enums + enum ModerationReportType { REVIEW PROFILE @@ -2674,6 +2714,7 @@ enum SystemJobStatus { } // Content Moderation Models + model ModerationReport { id String @id @default(uuid()) type ModerationReportType @@ -2737,6 +2778,7 @@ model ModerationAction { } // Profile Verification Models + model ConsultantProfileVerification { id String @id @default(uuid()) status ProfileVerificationStatus @default(PENDING) @@ -2791,6 +2833,7 @@ model ProfileVerificationDocument { } // System Jobs Models + model SystemJobExecution { id String @id @default(uuid()) jobId String // Maps to job configuration ID @@ -2875,3 +2918,18 @@ model MaintenanceWindow { @@map("maintenance_windows") } + +model SsoProvider { + id String @id + issuer String + oidcConfig String? + samlConfig String? + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + providerId String + organizationId String? + domain String + + @@unique([providerId]) + @@map("ssoProvider") +} From 39ead2955d46683634c49b83dad5bcaeff45025f Mon Sep 17 00:00:00 2001 From: teetangh Date: Fri, 10 Apr 2026 06:05:38 +0530 Subject: [PATCH 003/415] feat(org): add PROVIDER feature flag, org auth helpers, middleware route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lib/auth-helpers.ts | 169 +++++++++++++++++++++++++++++++++++++++++++ lib/feature-flags.ts | 38 ++++++++++ middleware.ts | 1 + 3 files changed, 208 insertions(+) create mode 100644 lib/feature-flags.ts diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index 96d85fbc2..f19b3eeae 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -2,6 +2,11 @@ import { getSession } from "@/lib/auth-server"; import { NextResponse } from "next/server"; import type { Session } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import type { + OrganizationProfile, + OrganizationMemberProfile, + OrgMemberRole, +} from "@prisma/client"; /** * Requires API authentication and returns the session or an error response. @@ -257,3 +262,167 @@ export async function authorizeEventAccess( return null; } + +// ============================================================================ +// ORGANIZATION ACCESS HELPERS (ENTERPRISE) +// ============================================================================ + +/** + * Numeric rank for OrgMemberRole — higher = more privileged. + * + * Used for "minimum role" checks via {@link orgRoleSatisfies}. Note that + * ORG_SUPPORT and ORG_LEARNER deliberately sit between ORG_MANAGER and the + * absolute floor — they have legitimate access to view their own data but + * shouldn't be promoted above MANAGER without an explicit role change. + */ +const ORG_ROLE_RANK: Record = { + ORG_OWNER: 100, + ORG_ADMIN: 80, + ORG_MANAGER: 60, + ORG_CONSULTANT: 40, + ORG_SUPPORT: 30, + ORG_LEARNER: 20, +}; + +/** + * Whether `actual` role meets the `minimum` role requirement. + * + * Examples: + * orgRoleSatisfies("ORG_OWNER", "ORG_ADMIN") → true + * orgRoleSatisfies("ORG_LEARNER", "ORG_MANAGER") → false + * orgRoleSatisfies("ORG_ADMIN", "ORG_ADMIN") → true (>=) + */ +export function orgRoleSatisfies( + actual: OrgMemberRole, + minimum: OrgMemberRole, +): boolean { + return ORG_ROLE_RANK[actual] >= ORG_ROLE_RANK[minimum]; +} + +export type OrgAccessGrant = { + session: Session; + member: OrganizationMemberProfile; + org: OrganizationProfile; +}; + +/** + * Require that the session user is an active member of the specified + * organization (by `organizationId` — the BetterAuth `Organization.id`). + * + * Optionally require a minimum role. Roles are ranked via {@link ORG_ROLE_RANK} + * and compared with {@link orgRoleSatisfies}. + * + * **Platform admins (UserRole.ADMIN) bypass org membership checks.** They can + * manage any org for operability/support reasons. STAFF do NOT bypass — they're + * platform-side, not org-side. + * + * @returns On success: { session, member, org } where `org` is the + * OrganizationProfile (with full enterprise fields) and `member` is + * the user's OrganizationMemberProfile (with the typed role enum). + * On failure: { error: NextResponse } with 401/403/404 as appropriate. + * + * @example + * export async function GET(_req, { params }) { + * const access = await requireOrgAccess((await params).orgId); + * if (access.error) return access.error; + * // access.session, access.member, access.org are all defined + * } + */ +export async function requireOrgAccess( + organizationId: string, + minimumRole?: OrgMemberRole, +): Promise<({ error?: never } & OrgAccessGrant) | { error: NextResponse }> { + const auth = await requireApiAuth(); + if (auth.error) return { error: auth.error }; + + // Resolve OrganizationProfile by the BetterAuth Organization.id + const org = await prisma.organizationProfile.findUnique({ + where: { organizationId }, + }); + if (!org) { + return { + error: NextResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ), + }; + } + + if (org.status === "DEACTIVATED") { + return { + error: NextResponse.json( + { error: "Organization has been deactivated" }, + { status: 403 }, + ), + }; + } + + const userId = auth.session.user.id; + + // Platform admins bypass org membership checks for operability. + // They get a synthesized OWNER-rank member record so callers don't have to + // special-case admin paths. + if (auth.session.user.role === "ADMIN") { + const stub: OrganizationMemberProfile = { + id: `__admin_stub_${userId}`, + memberId: `__admin_stub_${userId}`, + organizationProfileId: org.id, + role: "ORG_OWNER", + status: "ACTIVE", + consultantProfileId: null, + consulteeProfileId: null, + customConsultantPayoutRate: null, + seatAssignedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + return { session: auth.session, member: stub, org }; + } + + // Look up the typed member profile by joining through BetterAuth's Member. + const member = await prisma.organizationMemberProfile.findFirst({ + where: { + organizationProfileId: org.id, + member: { userId }, + }, + }); + + if (!member) { + return { + error: NextResponse.json( + { error: "Not a member of this organization" }, + { status: 403 }, + ), + }; + } + + if (member.status !== "ACTIVE") { + return { + error: NextResponse.json( + { error: `Membership is ${member.status.toLowerCase()}` }, + { status: 403 }, + ), + }; + } + + if (minimumRole && !orgRoleSatisfies(member.role, minimumRole)) { + return { + error: NextResponse.json( + { error: `Forbidden — ${minimumRole} or higher required` }, + { status: 403 }, + ), + }; + } + + return { session: auth.session, member, org }; +} + +/** + * Convenience wrapper around {@link requireOrgAccess} for owner-only operations: + * settings mutation, billing changes, payout account, organization deletion. + */ +export async function requireOrgOwner( + organizationId: string, +): Promise<({ error?: never } & OrgAccessGrant) | { error: NextResponse }> { + return requireOrgAccess(organizationId, "ORG_OWNER"); +} diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts new file mode 100644 index 000000000..594788c42 --- /dev/null +++ b/lib/feature-flags.ts @@ -0,0 +1,38 @@ +/** + * Feature flags for gradual feature rollout. + * + * Flags are read from process.env at module load time. Setting a flag in the + * deployment environment requires a redeploy — this is intentional. We don't + * want runtime flag flips for billing-affecting features. + * + * To enable a flag locally, add it to your `.env` file or set it in your + * shell before running `npm run dev`. + */ + +/** + * PROVIDER organizations (consultant agencies that host multiple consultants + * and capture a slice of every booking via a 3-way revenue split). + * + * The schema, role enum, code paths, and API routes for PROVIDER orgs all + * exist in this codebase but are gated by this flag. When false (the default + * pre-MVP): + * - POST /api/organizations rejects `kind === "PROVIDER"` with 501 + * - POST /api/organizations/[id]/members rejects `role === "ORG_CONSULTANT"` with 501 + * - The org-create dashboard form hides PROVIDER from the kind dropdown + * - /api/organizations/[id]/payouts, /payout-account, /consultants return 501 + * - /dashboard/organization/[id]/{consultants,payouts} are unreachable (nav links hidden) + * - The earnings split in lib/payments/payouts/earnings-service.ts takes + * the BUYER (= unchanged) path even if the schema's `kind === PROVIDER` + * + * To enable for a real PROVIDER customer: + * 1. Set `ENABLE_PROVIDER_ORGS=true` in the deployment environment + * 2. Redeploy + * 3. Verify the rejection paths above now succeed + * 4. See Issue #646 for the full PROVIDER follow-up checklist + * + * The flag is intentionally NOT a runtime toggle — flipping it mid-stream + * would mean some payments use BUYER split logic and others use PROVIDER, + * which is a compliance nightmare. + */ +export const ENABLE_PROVIDER_ORGS = + process.env.ENABLE_PROVIDER_ORGS === "true"; diff --git a/middleware.ts b/middleware.ts index 382fb3dcd..8314c5e1a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -48,6 +48,7 @@ const ROUTE_PATTERNS = { "/api/slots/", // Private: appointment slot data and mutations "/api/admin/", // Private: platform admin operations (handler-level auth still runs) "/api/staff/", // Private: platform staff operations (handler-level auth still runs) + "/api/organizations/", // Private: enterprise org CRUD, members, billing, sso (handler-level requireOrgAccess still runs) ], // Note: /api/auth/ must remain public for BetterAuth to work // /api/user/consultants routes are public for explore page (verification filter enforced in API) From cdd35078da73a7cd14e01743a02816820ae5b7e5 Mon Sep 17 00:00:00 2001 From: teetangh Date: Fri, 10 Apr 2026 06:17:58 +0530 Subject: [PATCH 004/415] feat(api/organizations): scaffold org CRUD, billing, credits, sso, plans 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. --- .../organizations/[orgId]/analytics/route.ts | 97 ++++++ .../[orgId]/billing/generate-invoice/route.ts | 119 +++++++ .../billing/invoices/[invoiceId]/pay/route.ts | 65 ++++ .../[orgId]/billing/invoices/route.ts | 141 +++++++++ .../organizations/[orgId]/billing/route.ts | 105 ++++++ .../[orgId]/consultants/route.ts | 56 ++++ .../[orgId]/credits/purchase/route.ts | 80 +++++ .../[orgId]/credits/purchases/route.ts | 54 ++++ .../organizations/[orgId]/credits/route.ts | 58 ++++ .../invitations/[invitationId]/route.ts | 53 ++++ .../[orgId]/invitations/route.ts | 145 +++++++++ .../organizations/[orgId]/learners/route.ts | 74 +++++ .../[orgId]/members/[memberId]/route.ts | 171 ++++++++++ .../organizations/[orgId]/members/route.ts | 169 ++++++++++ .../[orgId]/payout-account/route.ts | 109 +++++++ .../organizations/[orgId]/payouts/route.ts | 67 ++++ .../[orgId]/plans/[planId]/route.ts | 150 +++++++++ app/api/organizations/[orgId]/plans/route.ts | 101 ++++++ app/api/organizations/[orgId]/route.ts | 209 ++++++++++++ .../organizations/[orgId]/settings/route.ts | 40 +++ .../sso/providers/[providerId]/route.ts | 44 +++ .../[orgId]/sso/providers/route.ts | 116 +++++++ app/api/organizations/[orgId]/sso/route.ts | 105 ++++++ .../organizations/invitations/accept/route.ts | 187 +++++++++++ app/api/organizations/route.ts | 299 ++++++++++++++++++ .../components/EventPlannerForClass.tsx | 1 + .../components/EventPlannerForWebinar.tsx | 1 + 27 files changed, 2816 insertions(+) create mode 100644 app/api/organizations/[orgId]/analytics/route.ts create mode 100644 app/api/organizations/[orgId]/billing/generate-invoice/route.ts create mode 100644 app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts create mode 100644 app/api/organizations/[orgId]/billing/invoices/route.ts create mode 100644 app/api/organizations/[orgId]/billing/route.ts create mode 100644 app/api/organizations/[orgId]/consultants/route.ts create mode 100644 app/api/organizations/[orgId]/credits/purchase/route.ts create mode 100644 app/api/organizations/[orgId]/credits/purchases/route.ts create mode 100644 app/api/organizations/[orgId]/credits/route.ts create mode 100644 app/api/organizations/[orgId]/invitations/[invitationId]/route.ts create mode 100644 app/api/organizations/[orgId]/invitations/route.ts create mode 100644 app/api/organizations/[orgId]/learners/route.ts create mode 100644 app/api/organizations/[orgId]/members/[memberId]/route.ts create mode 100644 app/api/organizations/[orgId]/members/route.ts create mode 100644 app/api/organizations/[orgId]/payout-account/route.ts create mode 100644 app/api/organizations/[orgId]/payouts/route.ts create mode 100644 app/api/organizations/[orgId]/plans/[planId]/route.ts create mode 100644 app/api/organizations/[orgId]/plans/route.ts create mode 100644 app/api/organizations/[orgId]/route.ts create mode 100644 app/api/organizations/[orgId]/settings/route.ts create mode 100644 app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts create mode 100644 app/api/organizations/[orgId]/sso/providers/route.ts create mode 100644 app/api/organizations/[orgId]/sso/route.ts create mode 100644 app/api/organizations/invitations/accept/route.ts create mode 100644 app/api/organizations/route.ts diff --git a/app/api/organizations/[orgId]/analytics/route.ts b/app/api/organizations/[orgId]/analytics/route.ts new file mode 100644 index 000000000..382998822 --- /dev/null +++ b/app/api/organizations/[orgId]/analytics/route.ts @@ -0,0 +1,97 @@ +/** + * Org analytics — high-level stat cards for the dashboard analytics page. + * + * GET — ORG_MANAGER+. Returns counts and aggregates that the analytics page + * needs without expensive joins. Charting data (timeseries) lives in a future + * deeper analytics endpoint deferred to a separate PR. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_MANAGER"); + if (access.error) return access.error; + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + + const [ + memberCount, + learnerCount, + planCount, + monthBookings, + lastMonthBookings, + monthGross, + ] = await Promise.all([ + prisma.organizationMemberProfile.count({ + where: { + organizationProfileId: access.org.id, + status: "ACTIVE", + }, + }), + prisma.organizationMemberProfile.count({ + where: { + organizationProfileId: access.org.id, + role: "ORG_LEARNER", + status: "ACTIVE", + }, + }), + prisma.organizationPlan.count({ + where: { + organizationProfileId: access.org.id, + isActive: true, + }, + }), + prisma.payment.count({ + where: { + organizationProfileId: access.org.id, + createdAt: { gte: monthStart }, + }, + }), + prisma.payment.count({ + where: { + organizationProfileId: access.org.id, + createdAt: { gte: lastMonthStart, lt: monthStart }, + }, + }), + prisma.payment.aggregate({ + where: { + organizationProfileId: access.org.id, + createdAt: { gte: monthStart }, + }, + _sum: { amount: true }, + }), + ]); + + return NextResponse.json({ + members: { total: memberCount, learners: learnerCount }, + plans: { active: planCount }, + bookings: { + monthToDate: monthBookings, + lastMonth: lastMonthBookings, + deltaPct: lastMonthBookings + ? ((monthBookings - lastMonthBookings) / lastMonthBookings) * 100 + : null, + }, + revenue: { + monthToDateGross: monthGross._sum.amount ?? 0, + }, + seatsTotal: access.org.seatsTotal, + seatsUsed: access.org.seatsUsed, + }); + } catch (error) { + console.error("[API /organizations/[orgId]/analytics GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch analytics" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/billing/generate-invoice/route.ts b/app/api/organizations/[orgId]/billing/generate-invoice/route.ts new file mode 100644 index 000000000..def852ebc --- /dev/null +++ b/app/api/organizations/[orgId]/billing/generate-invoice/route.ts @@ -0,0 +1,119 @@ +/** + * Manual trigger for the INVOICED_MONTHLY invoice generator. + * + * POST — ORG_OWNER. Aggregates unbilled Payment rows for the calling org and + * rolls them into a single OrganizationInvoice. Useful for testing and for + * orgs that want to close out a billing cycle ahead of the monthly cron. + * + * Phase K will move the core aggregation logic into + * `lib/payments/operations/org-invoicing.ts` and call it both from this route + * and the monthly Inngest cron. For now we inline a minimum-viable version so + * the dashboard can render real data. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +function generateInvoiceNumber(orgSlug: string): string { + const yyyymm = new Date().toISOString().slice(0, 7).replace("-", ""); + const random = Math.random().toString(36).slice(2, 6).toUpperCase(); + return `INV-${orgSlug.slice(0, 8).toUpperCase()}-${yyyymm}-${random}`; +} + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + if (access.org.billingMode !== "INVOICED_MONTHLY") { + return NextResponse.json( + { + error: `This operation is only valid for INVOICED_MONTHLY orgs (current mode: ${access.org.billingMode}).`, + }, + { status: 400 }, + ); + } + + // Aggregate every payment tagged to this org that is not yet billed. + // Joining `appointment` so the line items can carry the booking type. + const unbilled = await prisma.payment.findMany({ + where: { + organizationProfileId: access.org.id, + billableToOrgInvoiceId: null, + }, + select: { + id: true, + amount: true, + createdAt: true, + appointment: { select: { appointmentType: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + + if (unbilled.length === 0) { + return NextResponse.json( + { error: "No unbilled payments to invoice for this period." }, + { status: 400 }, + ); + } + + const subtotal = unbilled.reduce((sum, p) => sum + (p.amount ?? 0), 0); + const items = unbilled.map((p) => ({ + paymentId: p.id, + description: `Booking (${p.appointment?.appointmentType ?? "UNKNOWN"})`, + quantity: 1, + unitPrice: p.amount ?? 0, + })); + + const periodStart = unbilled[0].createdAt; + const periodEnd = unbilled[unbilled.length - 1].createdAt; + + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + access.org.paymentTermsDays); + + const organization = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { slug: true }, + }); + + const invoice = await prisma.$transaction(async (tx) => { + const created = await tx.organizationInvoice.create({ + data: { + organizationProfileId: access.org.id, + invoiceNumber: generateInvoiceNumber(organization?.slug ?? "ORG"), + amount: subtotal, + currency: "INR", + status: "SENT", + items: items as unknown as object, + billingCycleStart: periodStart, + billingCycleEnd: periodEnd, + autoGenerated: false, + dueDate, + }, + }); + + await tx.payment.updateMany({ + where: { id: { in: unbilled.map((p) => p.id) } }, + data: { billableToOrgInvoiceId: created.id }, + }); + + return created; + }); + + return NextResponse.json({ invoice }, { status: 201 }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/billing/generate-invoice POST] error:", + error, + ); + return NextResponse.json( + { error: "Failed to generate invoice" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts b/app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts new file mode 100644 index 000000000..5cc16cfe8 --- /dev/null +++ b/app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts @@ -0,0 +1,65 @@ +/** + * Initiate gateway payment for an outstanding org invoice. + * + * POST — ORG_OWNER. Returns the gateway intent / order ID for the client SDK. + * + * Phase K replaces the stub return below with a real gateway intent + * (Razorpay/Stripe) and a webhook handler that flips the invoice to PAID. + * For now we 200 with `pendingPhaseK` so the dashboard can wire up the button + * without blocking on payment plumbing. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string; invoiceId: string }> }, +) { + try { + const { orgId, invoiceId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const invoice = await prisma.organizationInvoice.findFirst({ + where: { id: invoiceId, organizationProfileId: access.org.id }, + }); + if (!invoice) { + return NextResponse.json( + { error: "Invoice not found" }, + { status: 404 }, + ); + } + if (invoice.status === "PAID") { + return NextResponse.json( + { error: "Invoice has already been paid." }, + { status: 400 }, + ); + } + if (invoice.status === "CANCELLED") { + return NextResponse.json( + { error: "Invoice has been cancelled." }, + { status: 400 }, + ); + } + + return NextResponse.json({ + pendingPhaseK: true, + invoiceId: invoice.id, + amount: invoice.amount, + currency: invoice.currency, + message: + "Gateway payment for org invoices ships in Phase K (org-invoicing service).", + }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/billing/invoices/[invoiceId]/pay POST] error:", + error, + ); + return NextResponse.json( + { error: "Failed to initiate payment" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/billing/invoices/route.ts b/app/api/organizations/[orgId]/billing/invoices/route.ts new file mode 100644 index 000000000..2092d2c99 --- /dev/null +++ b/app/api/organizations/[orgId]/billing/invoices/route.ts @@ -0,0 +1,141 @@ +/** + * Org invoices — GET (list) / POST (manual create). + * + * GET — ORG_MANAGER+ + * POST — ORG_OWNER (manual ad-hoc invoice for special charges). + * Auto-generated invoices come from /billing/generate-invoice and the + * monthly Inngest cron in Phase K. + * + * PDF rendering is deferred to Issue #438 — `pdfUrl` stays null until then. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { OrgInvoiceStatus } from "@prisma/client"; + +const lineItemSchema = z.object({ + description: z.string().min(1).max(200), + quantity: z.number().int().positive(), + unitPrice: z.number().int().nonnegative(), // in paise +}); + +const createInvoiceSchema = z.object({ + items: z.array(lineItemSchema).min(1), + currency: z.string().length(3).default("INR"), + taxRate: z.number().min(0).max(1).optional(), + hsnCode: z.string().optional(), + gstin: z.string().optional(), + dueDate: z.string().datetime().optional(), + notes: z.string().max(1000).optional(), +}); + +function generateInvoiceNumber(orgSlug: string): string { + const yyyymm = new Date().toISOString().slice(0, 7).replace("-", ""); + const random = Math.random().toString(36).slice(2, 6).toUpperCase(); + return `INV-${orgSlug.slice(0, 8).toUpperCase()}-${yyyymm}-${random}`; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const status = url.searchParams.get("status") as OrgInvoiceStatus | null; + const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "20"), 100); + const offset = Math.max(parseInt(url.searchParams.get("offset") ?? "0"), 0); + + const where = { + organizationProfileId: access.org.id, + ...(status && { status }), + }; + + const [invoices, total] = await Promise.all([ + prisma.organizationInvoice.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.organizationInvoice.count({ where }), + ]); + + return NextResponse.json({ invoices, total, limit, offset }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/billing/invoices GET] error:", + error, + ); + return NextResponse.json( + { error: "Failed to fetch invoices" }, + { status: 500 }, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = createInvoiceSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { items, currency, taxRate, hsnCode, gstin, dueDate } = parsed.data; + const subtotal = items.reduce( + (sum, item) => sum + item.quantity * item.unitPrice, + 0, + ); + const taxAmount = taxRate ? Math.round(subtotal * taxRate) : 0; + const amount = subtotal + taxAmount; + + const organization = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { slug: true }, + }); + + const invoice = await prisma.organizationInvoice.create({ + data: { + organizationProfileId: access.org.id, + invoiceNumber: generateInvoiceNumber(organization?.slug ?? "ORG"), + amount, + currency, + status: "DRAFT", + items: items as unknown as object, + taxAmount: taxRate ? taxAmount : null, + taxRate: taxRate ?? null, + hsnCode: hsnCode ?? "999293", + gstin: gstin ?? null, + dueDate: dueDate ? new Date(dueDate) : null, + autoGenerated: false, + }, + }); + + return NextResponse.json({ invoice }, { status: 201 }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/billing/invoices POST] error:", + error, + ); + return NextResponse.json( + { error: "Failed to create invoice" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/billing/route.ts b/app/api/organizations/[orgId]/billing/route.ts new file mode 100644 index 000000000..6927ee717 --- /dev/null +++ b/app/api/organizations/[orgId]/billing/route.ts @@ -0,0 +1,105 @@ +/** + * Org billing summary — high-level stats card data for the billing dashboard. + * + * GET — ORG_MANAGER+; returns this-month gross, outstanding invoice total, + * billing mode, and (for SEAT_PACK orgs) credit pool snapshot. Cheap aggregate + * query — keep it lean. Detail breakdowns live in /billing/invoices and + * /credits. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_MANAGER"); + if (access.error) return access.error; + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + + const [ + monthAggregate, + outstandingAggregate, + pendingChargesAggregate, + creditPool, + ] = await Promise.all([ + // This month's gross — sum of payments tagged to this org regardless of mode. + prisma.payment.aggregate({ + where: { + organizationProfileId: access.org.id, + createdAt: { gte: monthStart }, + }, + _sum: { amount: true }, + _count: true, + }), + + // Outstanding invoices: SENT or OVERDUE. + prisma.organizationInvoice.aggregate({ + where: { + organizationProfileId: access.org.id, + status: { in: ["SENT", "OVERDUE"] }, + }, + _sum: { amount: true }, + _count: true, + }), + + // INVOICED_MONTHLY pending charges: payments not yet rolled into an invoice. + access.org.billingMode === "INVOICED_MONTHLY" + ? prisma.payment.aggregate({ + where: { + organizationProfileId: access.org.id, + billableToOrgInvoiceId: null, + }, + _sum: { amount: true }, + _count: true, + }) + : Promise.resolve(null), + + // SEAT_PACK credit pool snapshot. + access.org.billingMode === "SEAT_PACK" + ? prisma.orgCreditPool.findUnique({ + where: { organizationProfileId: access.org.id }, + }) + : Promise.resolve(null), + ]); + + return NextResponse.json({ + billingMode: access.org.billingMode, + currency: "INR", + monthToDate: { + gross: monthAggregate._sum.amount ?? 0, + paymentCount: monthAggregate._count, + }, + outstanding: { + amount: outstandingAggregate._sum.amount ?? 0, + invoiceCount: outstandingAggregate._count, + }, + pendingCharges: pendingChargesAggregate + ? { + amount: pendingChargesAggregate._sum.amount ?? 0, + paymentCount: pendingChargesAggregate._count, + } + : null, + creditPool: creditPool + ? { + balance: creditPool.balance, + totalPurchased: creditPool.totalPurchased, + } + : null, + orgInvoiceCreditLimit: access.org.orgInvoiceCreditLimit, + paymentTermsDays: access.org.paymentTermsDays, + }); + } catch (error) { + console.error("[API /organizations/[orgId]/billing GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch billing summary" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/consultants/route.ts b/app/api/organizations/[orgId]/consultants/route.ts new file mode 100644 index 000000000..df3a12d1d --- /dev/null +++ b/app/api/organizations/[orgId]/consultants/route.ts @@ -0,0 +1,56 @@ +/** + * Org-affiliated consultants (PROVIDER feature). + * + * GET — gated behind ENABLE_PROVIDER_ORGS. Lists OrganizationMemberProfile + * rows where role === ORG_CONSULTANT. Returns 501 with the flag name when + * disabled. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + if (!ENABLE_PROVIDER_ORGS) { + return NextResponse.json( + { + error: "PROVIDER organization consultants are not yet available.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const consultants = await prisma.organizationMemberProfile.findMany({ + where: { + organizationProfileId: access.org.id, + role: "ORG_CONSULTANT", + status: "ACTIVE", + }, + include: { + member: { + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + consultantProfile: { + select: { + id: true, + headline: true, + rating: true, + isVerified: true, + }, + }, + }, + }); + + return NextResponse.json({ consultants }); +} diff --git a/app/api/organizations/[orgId]/credits/purchase/route.ts b/app/api/organizations/[orgId]/credits/purchase/route.ts new file mode 100644 index 000000000..bf330a68e --- /dev/null +++ b/app/api/organizations/[orgId]/credits/purchase/route.ts @@ -0,0 +1,80 @@ +/** + * Initiate a SEAT_PACK credit purchase. + * + * POST — ORG_OWNER. Creates a pending OrgCreditPurchase row and (in Phase J) + * returns a gateway intent the client SDK can complete. The webhook handler + * — also added in Phase J — flips the purchase to confirmed, increments + * OrgCreditPool.balance, and writes a ledger row. + * + * For now we 200 with `pendingPhaseJ` so the dashboard "Buy credits" button + * has an endpoint to call without blocking on payment plumbing. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +const purchaseSchema = z.object({ + amountPaise: z.number().int().positive(), + // 1 paise = 1 credit unit (per OrgCreditPool docstring) +}); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + if (access.org.billingMode !== "SEAT_PACK") { + return NextResponse.json( + { + error: `This operation is only valid for SEAT_PACK orgs (current mode: ${access.org.billingMode}).`, + }, + { status: 400 }, + ); + } + + const body = await req.json(); + const parsed = purchaseSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { amountPaise } = parsed.data; + + const purchase = await prisma.orgCreditPurchase.create({ + data: { + organizationProfileId: access.org.id, + creditsPurchased: amountPaise, + amountPaid: amountPaise, + currency: "INR", + }, + }); + + return NextResponse.json( + { + pendingPhaseJ: true, + purchase, + message: + "Gateway checkout for credit packs ships in Phase J (org-credits service).", + }, + { status: 201 }, + ); + } catch (error) { + console.error( + "[API /organizations/[orgId]/credits/purchase POST] error:", + error, + ); + return NextResponse.json( + { error: "Failed to initiate credit purchase" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/credits/purchases/route.ts b/app/api/organizations/[orgId]/credits/purchases/route.ts new file mode 100644 index 000000000..33c8675bc --- /dev/null +++ b/app/api/organizations/[orgId]/credits/purchases/route.ts @@ -0,0 +1,54 @@ +/** + * SEAT_PACK credit purchase history. + * + * GET — ORG_MANAGER+. Lists past credit pack purchases regardless of payment + * status. The dashboard uses this for the "Purchase history" tab on the + * credits page. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_MANAGER"); + if (access.error) return access.error; + + const url = new URL(req.url); + const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "20"), 100); + const offset = Math.max(parseInt(url.searchParams.get("offset") ?? "0"), 0); + + const [purchases, total] = await Promise.all([ + prisma.orgCreditPurchase.findMany({ + where: { organizationProfileId: access.org.id }, + include: { + payment: { + select: { id: true, paymentStatus: true, paymentGateway: true }, + }, + }, + orderBy: { purchasedAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.orgCreditPurchase.count({ + where: { organizationProfileId: access.org.id }, + }), + ]); + + return NextResponse.json({ purchases, total, limit, offset }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/credits/purchases GET] error:", + error, + ); + return NextResponse.json( + { error: "Failed to fetch purchases" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/credits/route.ts b/app/api/organizations/[orgId]/credits/route.ts new file mode 100644 index 000000000..c173ab496 --- /dev/null +++ b/app/api/organizations/[orgId]/credits/route.ts @@ -0,0 +1,58 @@ +/** + * SEAT_PACK credit pool — GET balance + recent ledger entries. + * + * GET — ORG_MANAGER+. Returns 200 with `null` pool data if the org isn't on + * SEAT_PACK billing (the dashboard hides the credits page in that case). + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_MANAGER"); + if (access.error) return access.error; + + if (access.org.billingMode !== "SEAT_PACK") { + return NextResponse.json({ + billingMode: access.org.billingMode, + pool: null, + ledger: [], + }); + } + + const url = new URL(req.url); + const ledgerLimit = Math.min( + parseInt(url.searchParams.get("ledgerLimit") ?? "20"), + 100, + ); + + const [pool, ledger] = await Promise.all([ + prisma.orgCreditPool.findUnique({ + where: { organizationProfileId: access.org.id }, + }), + prisma.orgCreditLedger.findMany({ + where: { organizationProfileId: access.org.id }, + orderBy: { createdAt: "desc" }, + take: ledgerLimit, + }), + ]); + + return NextResponse.json({ + billingMode: "SEAT_PACK", + pool, + ledger, + }); + } catch (error) { + console.error("[API /organizations/[orgId]/credits GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch credits" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/invitations/[invitationId]/route.ts b/app/api/organizations/[orgId]/invitations/[invitationId]/route.ts new file mode 100644 index 000000000..8afdcd86c --- /dev/null +++ b/app/api/organizations/[orgId]/invitations/[invitationId]/route.ts @@ -0,0 +1,53 @@ +/** + * Single invitation — DELETE (revoke). + * + * Auth: ORG_ADMIN+. Sets status="revoked" rather than hard-deleting so the + * audit trail survives. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string; invitationId: string }> }, +) { + try { + const { orgId, invitationId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const invitation = await prisma.invitation.findFirst({ + where: { id: invitationId, organizationId: orgId }, + }); + if (!invitation) { + return NextResponse.json( + { error: "Invitation not found" }, + { status: 404 }, + ); + } + if (invitation.status !== "pending") { + return NextResponse.json( + { error: `Invitation is already ${invitation.status}` }, + { status: 400 }, + ); + } + + await prisma.invitation.update({ + where: { id: invitationId }, + data: { status: "revoked" }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/invitations/[invitationId] DELETE] error:", + error, + ); + return NextResponse.json( + { error: "Failed to revoke invitation" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/invitations/route.ts b/app/api/organizations/[orgId]/invitations/route.ts new file mode 100644 index 000000000..7aecd1ae4 --- /dev/null +++ b/app/api/organizations/[orgId]/invitations/route.ts @@ -0,0 +1,145 @@ +/** + * Organization invitations — GET (list) / POST (create). + * + * Invitations target an email address that may or may not have a platform + * account yet. The invited user accepts via /api/organizations/invitations/accept + * which creates the Member + OrganizationMemberProfile rows. + * + * Auth: + * GET — any active org member + * POST — ORG_ADMIN+ + * + * ORG_CONSULTANT and ORG_SUPPORT roles in the invite are gated by + * ENABLE_PROVIDER_ORGS. Email delivery is fire-and-forget — failure to send + * does not roll back the invitation row (the org admin can resend). + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { OrgMemberRole } from "@prisma/client"; + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.nativeEnum(OrgMemberRole).default("ORG_LEARNER"), +}); + +const PROVIDER_GATED_ROLES: OrgMemberRole[] = ["ORG_CONSULTANT", "ORG_SUPPORT"]; + +const INVITATION_TTL_DAYS = 14; + +function makeInviteToken(): string { + // Cryptographically random 32-byte token, hex-encoded. + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const invitations = await prisma.invitation.findMany({ + where: { organizationId: orgId }, + include: { + inviter: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ invitations }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/invitations GET] error:", + error, + ); + return NextResponse.json( + { error: "Failed to fetch invitations" }, + { status: 500 }, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = inviteSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { email, role } = parsed.data; + + if (!ENABLE_PROVIDER_ORGS && PROVIDER_GATED_ROLES.includes(role)) { + return NextResponse.json( + { + error: `${role} role is gated behind PROVIDER orgs.`, + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + // Reject duplicate pending invites for the same (org, email) pair. + const existing = await prisma.invitation.findFirst({ + where: { organizationId: orgId, email, status: "pending" }, + }); + if (existing) { + return NextResponse.json( + { error: "An invitation for this email is already pending." }, + { status: 409 }, + ); + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + INVITATION_TTL_DAYS); + + // BetterAuth Invitation.id is a CUID — we use it as the accept token. + // We pass an explicit id so we control the token without an extra column. + const token = makeInviteToken(); + + const invitation = await prisma.invitation.create({ + data: { + id: token, + organizationId: orgId, + email, + role, + status: "pending", + expiresAt, + inviterId: access.session.user.id, + }, + }); + + // TODO(Phase H/email): wire up Resend transactional template + // ("you're invited to join {orgName}") with the accept link + // `${BASE_URL}/organizations/invite/${invitation.id}`. + + return NextResponse.json({ invitation }, { status: 201 }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/invitations POST] error:", + error, + ); + return NextResponse.json( + { error: "Failed to create invitation" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/learners/route.ts b/app/api/organizations/[orgId]/learners/route.ts new file mode 100644 index 000000000..6ebc2f1b9 --- /dev/null +++ b/app/api/organizations/[orgId]/learners/route.ts @@ -0,0 +1,74 @@ +/** + * Org-affiliated learners (BUYER + HYBRID). + * + * GET — any active member. Lists OrganizationMemberProfile rows where + * role === ORG_LEARNER. Used by the learners page on BUYER dashboards. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const url = new URL(req.url); + const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200); + const offset = Math.max(parseInt(url.searchParams.get("offset") ?? "0"), 0); + + const where = { + organizationProfileId: access.org.id, + role: "ORG_LEARNER" as const, + }; + + const [learners, total] = await Promise.all([ + prisma.organizationMemberProfile.findMany({ + where, + include: { + member: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.organizationMemberProfile.count({ where }), + ]); + + return NextResponse.json({ + learners: learners.map((l) => ({ + id: l.id, + memberId: l.memberId, + status: l.status, + seatAssignedAt: l.seatAssignedAt, + createdAt: l.createdAt, + user: l.member.user, + })), + total, + limit, + offset, + }); + } catch (error) { + console.error("[API /organizations/[orgId]/learners GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch learners" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/members/[memberId]/route.ts b/app/api/organizations/[orgId]/members/[memberId]/route.ts new file mode 100644 index 000000000..0ae592e7d --- /dev/null +++ b/app/api/organizations/[orgId]/members/[memberId]/route.ts @@ -0,0 +1,171 @@ +/** + * Single org member — PATCH (role/status) / DELETE (soft remove). + * + * Auth: ORG_ADMIN+ on both. The last ORG_OWNER cannot be demoted or removed. + * Role/status changes to ORG_CONSULTANT / ORG_SUPPORT are gated behind + * ENABLE_PROVIDER_ORGS. + * + * `memberId` in the URL refers to the OrganizationMemberProfile.id (our typed + * sibling), NOT the BetterAuth Member.id — the sibling is what UI code touches. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { OrgMemberRole, OrgMemberStatus } from "@prisma/client"; + +const patchMemberSchema = z.object({ + role: z.nativeEnum(OrgMemberRole).optional(), + status: z.nativeEnum(OrgMemberStatus).optional(), +}); + +const PROVIDER_GATED_ROLES: OrgMemberRole[] = ["ORG_CONSULTANT", "ORG_SUPPORT"]; + +async function countActiveOwners(organizationProfileId: string): Promise { + return prisma.organizationMemberProfile.count({ + where: { + organizationProfileId, + role: "ORG_OWNER", + status: "ACTIVE", + }, + }); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string; memberId: string }> }, +) { + try { + const { orgId, memberId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = patchMemberSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { role, status } = parsed.data; + + if (!ENABLE_PROVIDER_ORGS && role && PROVIDER_GATED_ROLES.includes(role)) { + return NextResponse.json( + { + error: `${role} role is gated behind PROVIDER orgs.`, + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + const target = await prisma.organizationMemberProfile.findFirst({ + where: { id: memberId, organizationProfileId: access.org.id }, + }); + if (!target) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + + // Last-owner guard — applies to both role demotion and status removal. + if (target.role === "ORG_OWNER" && target.status === "ACTIVE") { + const demoting = role !== undefined && role !== "ORG_OWNER"; + const deactivating = status !== undefined && status !== "ACTIVE"; + if (demoting || deactivating) { + const owners = await countActiveOwners(access.org.id); + if (owners <= 1) { + return NextResponse.json( + { error: "Cannot demote or remove the last organization owner." }, + { status: 400 }, + ); + } + } + } + + const [updated] = await prisma.$transaction(async (tx) => { + const profileUpdate = await tx.organizationMemberProfile.update({ + where: { id: memberId }, + data: { + ...(role !== undefined && { role }), + ...(status !== undefined && { status }), + }, + }); + if (role !== undefined) { + await tx.member.update({ + where: { id: target.memberId }, + data: { role }, + }); + } + return [profileUpdate]; + }); + + return NextResponse.json({ memberProfile: updated }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/members/[memberId] PATCH] error:", + error, + ); + return NextResponse.json( + { error: "Failed to update member" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string; memberId: string }> }, +) { + try { + const { orgId, memberId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const target = await prisma.organizationMemberProfile.findFirst({ + where: { id: memberId, organizationProfileId: access.org.id }, + }); + if (!target) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + + if (target.role === "ORG_OWNER" && target.status === "ACTIVE") { + const owners = await countActiveOwners(access.org.id); + if (owners <= 1) { + return NextResponse.json( + { error: "Cannot remove the last organization owner." }, + { status: 400 }, + ); + } + } + + await prisma.$transaction([ + prisma.organizationMemberProfile.update({ + where: { id: memberId }, + data: { status: "REMOVED" }, + }), + // Decrement seatsUsed if this was an ORG_LEARNER. + ...(target.role === "ORG_LEARNER" + ? [ + prisma.organizationProfile.update({ + where: { id: access.org.id }, + data: { seatsUsed: { decrement: 1 } }, + }), + ] + : []), + ]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/members/[memberId] DELETE] error:", + error, + ); + return NextResponse.json( + { error: "Failed to remove member" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/members/route.ts b/app/api/organizations/[orgId]/members/route.ts new file mode 100644 index 000000000..a852f5579 --- /dev/null +++ b/app/api/organizations/[orgId]/members/route.ts @@ -0,0 +1,169 @@ +/** + * Organization members — GET (list) / POST (add existing user). + * + * GET — any active org member (read) + * POST — ORG_ADMIN+ (add by user email; user must already exist on the platform). + * For invites to non-platform users, use /invitations instead. + * + * ORG_CONSULTANT and ORG_SUPPORT roles are gated behind ENABLE_PROVIDER_ORGS. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { OrgMemberRole } from "@prisma/client"; + +const addMemberSchema = z.object({ + email: z.string().email(), + role: z.nativeEnum(OrgMemberRole), +}); + +const PROVIDER_GATED_ROLES: OrgMemberRole[] = ["ORG_CONSULTANT", "ORG_SUPPORT"]; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const members = await prisma.organizationMemberProfile.findMany({ + where: { organizationProfileId: access.org.id }, + include: { + member: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ + members: members.map((m) => ({ + id: m.id, + memberId: m.memberId, + role: m.role, + status: m.status, + seatAssignedAt: m.seatAssignedAt, + createdAt: m.createdAt, + user: m.member.user, + })), + }); + } catch (error) { + console.error("[API /organizations/[orgId]/members GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch members" }, + { status: 500 }, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = addMemberSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { email, role } = parsed.data; + + if (!ENABLE_PROVIDER_ORGS && PROVIDER_GATED_ROLES.includes(role)) { + return NextResponse.json( + { + error: `${role} role is gated behind PROVIDER orgs.`, + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, consultantProfileId: true, consulteeProfileId: true }, + }); + if (!user) { + return NextResponse.json( + { error: "No user with that email exists. Send an invitation instead." }, + { status: 404 }, + ); + } + + const existing = await prisma.member.findUnique({ + where: { + organizationId_userId: { organizationId: orgId, userId: user.id }, + }, + }); + if (existing) { + return NextResponse.json( + { error: "User is already a member of this organization." }, + { status: 409 }, + ); + } + + const result = await prisma.$transaction(async (tx) => { + const member = await tx.member.create({ + data: { + organizationId: orgId, + userId: user.id, + role, + }, + }); + + const memberProfile = await tx.organizationMemberProfile.create({ + data: { + memberId: member.id, + organizationProfileId: access.org.id, + role, + status: "ACTIVE", + // ORG_LEARNER → link consultee profile (BUYER); ORG_CONSULTANT → link consultant profile (PROVIDER, gated) + consulteeProfileId: + role === "ORG_LEARNER" ? user.consulteeProfileId : null, + consultantProfileId: + role === "ORG_CONSULTANT" ? user.consultantProfileId : null, + seatAssignedAt: role === "ORG_LEARNER" ? new Date() : null, + }, + }); + + // Bump seatsUsed for BUYER seat tracking. + if (role === "ORG_LEARNER") { + await tx.organizationProfile.update({ + where: { id: access.org.id }, + data: { seatsUsed: { increment: 1 } }, + }); + } + + return { member, memberProfile }; + }); + + return NextResponse.json({ memberProfile: result.memberProfile }, { status: 201 }); + } catch (error) { + console.error("[API /organizations/[orgId]/members POST] error:", error); + return NextResponse.json( + { error: "Failed to add member" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/payout-account/route.ts b/app/api/organizations/[orgId]/payout-account/route.ts new file mode 100644 index 000000000..ef32ae00b --- /dev/null +++ b/app/api/organizations/[orgId]/payout-account/route.ts @@ -0,0 +1,109 @@ +/** + * Org payout account (PROVIDER feature) — GET / PUT / DELETE. + * + * All gated behind ENABLE_PROVIDER_ORGS. Returns 501 with the flag name when + * disabled. The full implementation lives behind the flag and ships when the + * first PROVIDER customer arrives — see Issue #646. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; + +function notImplemented() { + return NextResponse.json( + { + error: "PROVIDER organization payout accounts are not yet available.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); +} + +const putAccountSchema = z.object({ + accountHolderName: z.string().min(2).max(100), + accountNumber: z.string().min(4).max(50), + bankName: z.string().min(2).max(100), + ifscCode: z.string().min(4).max(20).optional(), + routingNumber: z.string().optional(), + swiftCode: z.string().optional(), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + if (!ENABLE_PROVIDER_ORGS) return notImplemented(); + + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const account = await prisma.organizationPayoutAccount.findUnique({ + where: { organizationProfileId: access.org.id }, + }); + return NextResponse.json({ account }); +} + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + if (!ENABLE_PROVIDER_ORGS) return notImplemented(); + + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = putAccountSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { accountNumber, ...rest } = parsed.data; + // STUB encryption — Issue #646 covers replacing this with libsodium / KMS. + const accountNumberEncrypted = Buffer.from(accountNumber).toString("base64"); + const accountNumberLast4 = accountNumber.slice(-4); + + const account = await prisma.organizationPayoutAccount.upsert({ + where: { organizationProfileId: access.org.id }, + create: { + organizationProfileId: access.org.id, + accountNumberEncrypted, + accountNumberLast4, + ...rest, + }, + update: { + accountNumberEncrypted, + accountNumberLast4, + ...rest, + }, + }); + + return NextResponse.json({ account }); +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + if (!ENABLE_PROVIDER_ORGS) return notImplemented(); + + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + await prisma.organizationPayoutAccount + .delete({ + where: { organizationProfileId: access.org.id }, + }) + .catch(() => null); // idempotent — silently no-op if absent + + return NextResponse.json({ success: true }); +} diff --git a/app/api/organizations/[orgId]/payouts/route.ts b/app/api/organizations/[orgId]/payouts/route.ts new file mode 100644 index 000000000..4945db757 --- /dev/null +++ b/app/api/organizations/[orgId]/payouts/route.ts @@ -0,0 +1,67 @@ +/** + * Org payouts (PROVIDER feature). + * + * GET / POST — gated behind ENABLE_PROVIDER_ORGS. Returns 501 with the flag + * name when disabled. When the flag is flipped on, the handlers below will be + * fleshed out to query OrganizationPayout / OrganizationEarnings and create + * batch payouts via the same payout pipeline used by ConsultantPayouts. + * + * See Issue #646 for the deferred PROVIDER work. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; + +function notImplemented() { + return NextResponse.json( + { + error: + "PROVIDER organization payouts are not yet available.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + if (!ENABLE_PROVIDER_ORGS) return notImplemented(); + + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_MANAGER"); + if (access.error) return access.error; + + if (access.org.kind === "BUYER") { + return NextResponse.json( + { error: "BUYER orgs do not have payouts." }, + { status: 400 }, + ); + } + + const payouts = await prisma.organizationPayout.findMany({ + where: { organizationProfileId: access.org.id }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json({ payouts }); +} + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + if (!ENABLE_PROVIDER_ORGS) return notImplemented(); + + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + // PROVIDER payout batch creation will live here when the flag is flipped. + return NextResponse.json( + { error: "PROVIDER payout batch creation is pending implementation." }, + { status: 501 }, + ); +} diff --git a/app/api/organizations/[orgId]/plans/[planId]/route.ts b/app/api/organizations/[orgId]/plans/[planId]/route.ts new file mode 100644 index 000000000..692a27293 --- /dev/null +++ b/app/api/organizations/[orgId]/plans/[planId]/route.ts @@ -0,0 +1,150 @@ +/** + * Single org plan — GET / PATCH / DELETE. + * + * GET — any active member + * PATCH — ORG_ADMIN+ + * DELETE — ORG_ADMIN+ (soft delete via isActive=false) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { Prisma } from "@prisma/client"; + +const patchPlanSchema = z.object({ + title: z.string().trim().min(2).max(120).optional(), + description: z.string().max(2000).nullable().optional(), + price: z.number().int().nonnegative().optional(), + priceCurrency: z.string().length(3).optional(), + config: z.record(z.unknown()).optional(), + assignedConsultantIds: z.array(z.string().uuid()).optional(), + isActive: z.boolean().optional(), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string; planId: string }> }, +) { + try { + const { orgId, planId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const plan = await prisma.organizationPlan.findFirst({ + where: { id: planId, organizationProfileId: access.org.id }, + }); + if (!plan) { + return NextResponse.json({ error: "Plan not found" }, { status: 404 }); + } + return NextResponse.json({ plan }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/plans/[planId] GET] error:", + error, + ); + return NextResponse.json( + { error: "Failed to fetch plan" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string; planId: string }> }, +) { + try { + const { orgId, planId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = patchPlanSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + if ( + !ENABLE_PROVIDER_ORGS && + parsed.data.assignedConsultantIds && + parsed.data.assignedConsultantIds.length > 0 + ) { + return NextResponse.json( + { + error: + "Per-consultant plan assignment is gated behind PROVIDER orgs.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + const existing = await prisma.organizationPlan.findFirst({ + where: { id: planId, organizationProfileId: access.org.id }, + select: { id: true }, + }); + if (!existing) { + return NextResponse.json({ error: "Plan not found" }, { status: 404 }); + } + + const { config, ...rest } = parsed.data; + const plan = await prisma.organizationPlan.update({ + where: { id: planId }, + data: { + ...rest, + ...(config !== undefined && { + config: config as Prisma.InputJsonValue, + }), + }, + }); + return NextResponse.json({ plan }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/plans/[planId] PATCH] error:", + error, + ); + return NextResponse.json( + { error: "Failed to update plan" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string; planId: string }> }, +) { + try { + const { orgId, planId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const existing = await prisma.organizationPlan.findFirst({ + where: { id: planId, organizationProfileId: access.org.id }, + select: { id: true }, + }); + if (!existing) { + return NextResponse.json({ error: "Plan not found" }, { status: 404 }); + } + + await prisma.organizationPlan.update({ + where: { id: planId }, + data: { isActive: false }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/plans/[planId] DELETE] error:", + error, + ); + return NextResponse.json( + { error: "Failed to delete plan" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/plans/route.ts b/app/api/organizations/[orgId]/plans/route.ts new file mode 100644 index 000000000..197be188b --- /dev/null +++ b/app/api/organizations/[orgId]/plans/route.ts @@ -0,0 +1,101 @@ +/** + * Org-owned catalog plans (OrganizationPlan). + * + * GET — any active member; lists active plans + * POST — ORG_ADMIN+; creates a new plan template + * + * `assignedConsultantIds` is feature-flagged: a non-empty assignment is + * PROVIDER-only because it implies the org is fanning bookings out to + * specific consultants under its umbrella. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { AppointmentsType, Prisma } from "@prisma/client"; + +const createPlanSchema = z.object({ + planType: z.nativeEnum(AppointmentsType), + title: z.string().trim().min(2).max(120), + description: z.string().max(2000).optional(), + price: z.number().int().nonnegative(), // in paise + priceCurrency: z.string().length(3).default("INR"), + config: z.record(z.unknown()).default({}), + assignedConsultantIds: z.array(z.string().uuid()).default([]), + isActive: z.boolean().default(true), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const plans = await prisma.organizationPlan.findMany({ + where: { organizationProfileId: access.org.id }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json({ plans }); + } catch (error) { + console.error("[API /organizations/[orgId]/plans GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch plans" }, + { status: 500 }, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = createPlanSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + if ( + !ENABLE_PROVIDER_ORGS && + parsed.data.assignedConsultantIds.length > 0 + ) { + return NextResponse.json( + { + error: + "Per-consultant plan assignment is gated behind PROVIDER orgs.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + const plan = await prisma.organizationPlan.create({ + data: { + organizationProfileId: access.org.id, + ...parsed.data, + config: parsed.data.config as Prisma.InputJsonValue, + }, + }); + + return NextResponse.json({ plan }, { status: 201 }); + } catch (error) { + console.error("[API /organizations/[orgId]/plans POST] error:", error); + return NextResponse.json( + { error: "Failed to create plan" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/route.ts b/app/api/organizations/[orgId]/route.ts new file mode 100644 index 000000000..c9cc350dc --- /dev/null +++ b/app/api/organizations/[orgId]/route.ts @@ -0,0 +1,209 @@ +/** + * Organization resource — GET / PATCH / DELETE by Organization.id. + * + * GET — active member (read) + * PATCH — ORG_ADMIN (profile + branding + limited BetterAuth Organization fields) + * DELETE — ORG_OWNER (soft delete → status DEACTIVATED) + * + * Rate validation for PROVIDER orgs (platformCommissionRate + orgRetainRate + + * consultantPayoutRate must sum to 1.0) runs only when ENABLE_PROVIDER_ORGS is + * on AND the caller is actually editing those fields on a PROVIDER/HYBRID org. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { OrgSizeBucket } from "@prisma/client"; + +const patchOrgSchema = z.object({ + // BetterAuth Organization fields + name: z.string().trim().min(2).max(100).optional(), + logo: z.string().url().nullable().optional(), + + // OrganizationProfile fields + billingEmail: z.string().email().optional(), + description: z.string().max(2000).nullable().optional(), + industry: z.string().max(100).nullable().optional(), + sizeBucket: z.nativeEnum(OrgSizeBucket).nullable().optional(), + website: z.string().url().nullable().optional(), + bannerImage: z.string().url().nullable().optional(), + primaryColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .optional(), + secondaryColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .optional(), + defaultCancellationPolicy: z.string().max(5000).nullable().optional(), + defaultRefundPolicy: z.string().max(5000).nullable().optional(), + seatsTotal: z.number().int().positive().nullable().optional(), + orgInvoiceCreditLimit: z.number().int().nonnegative().nullable().optional(), + paymentTermsDays: z.number().int().min(1).max(120).optional(), + + // PROVIDER-only fields (feature-flagged) + platformCommissionRate: z.number().min(0).max(1).optional(), + orgRetainRate: z.number().min(0).max(1).optional(), + consultantPayoutRate: z.number().min(0).max(1).optional(), + autoApproveConsultants: z.boolean().optional(), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const organization = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { id: true, name: true, slug: true, logo: true, createdAt: true }, + }); + + return NextResponse.json({ + organization, + profile: access.org, + membership: { + role: access.member.role, + status: access.member.status, + }, + }); + } catch (error) { + console.error("[API /organizations/[orgId] GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch organization" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_ADMIN"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = patchOrgSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const data = parsed.data; + + // Rate-sum validation for PROVIDER orgs (FEATURE-FLAGGED). + const ratesTouched = + data.platformCommissionRate !== undefined || + data.orgRetainRate !== undefined || + data.consultantPayoutRate !== undefined; + if (ratesTouched) { + if (!ENABLE_PROVIDER_ORGS) { + return NextResponse.json( + { + error: + "Revenue split rates are only editable on PROVIDER organizations.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + if (access.org.kind === "BUYER") { + return NextResponse.json( + { error: "BUYER orgs do not have a consultant payout split." }, + { status: 400 }, + ); + } + const platform = + data.platformCommissionRate ?? access.org.platformCommissionRate; + const orgRetain = data.orgRetainRate ?? access.org.orgRetainRate; + const consultant = + data.consultantPayoutRate ?? access.org.consultantPayoutRate; + const sum = platform + orgRetain + consultant; + if (Math.abs(sum - 1) > 0.0001) { + return NextResponse.json( + { + error: `Revenue rates must sum to 1.0 (got ${sum.toFixed(4)})`, + }, + { status: 400 }, + ); + } + } + + // Split the patch between BetterAuth Organization (name, logo) and + // OrganizationProfile (everything else). + const { name, logo, ...profileFields } = data; + + const [updatedOrg, updatedProfile] = await prisma.$transaction([ + prisma.organization.update({ + where: { id: orgId }, + data: { + ...(name !== undefined && { name }), + ...(logo !== undefined && { logo }), + }, + }), + prisma.organizationProfile.update({ + where: { id: access.org.id }, + data: { + ...profileFields, + ...(logo !== undefined && { logo }), + }, + }), + ]); + + return NextResponse.json({ + organization: { + id: updatedOrg.id, + name: updatedOrg.name, + slug: updatedOrg.slug, + logo: updatedOrg.logo, + }, + profile: updatedProfile, + }); + } catch (error) { + console.error("[API /organizations/[orgId] PATCH] error:", error); + return NextResponse.json( + { error: "Failed to update organization" }, + { status: 500 }, + ); + } +} + +/** + * DELETE — soft delete via status=DEACTIVATED. We keep the Organization row + * intact so payment history and audit trails don't dangle. + */ +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgOwner(orgId); + if (access.error) return access.error; + + await prisma.organizationProfile.update({ + where: { id: access.org.id }, + data: { status: "DEACTIVATED" }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[API /organizations/[orgId] DELETE] error:", error); + return NextResponse.json( + { error: "Failed to delete organization" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/settings/route.ts b/app/api/organizations/[orgId]/settings/route.ts new file mode 100644 index 000000000..7a540ae7d --- /dev/null +++ b/app/api/organizations/[orgId]/settings/route.ts @@ -0,0 +1,40 @@ +/** + * Organization settings — GET / PATCH alias of /api/organizations/[orgId]. + * + * Kept as a separate route so the dashboard "settings" page has a stable URL + * that doesn't tangle with the resource itself. The implementation is a thin + * pass-through to the same Prisma logic. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId); + if (access.error) return access.error; + + const organization = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { id: true, name: true, slug: true, logo: true }, + }); + return NextResponse.json({ + organization, + profile: access.org, + }); + } catch (error) { + console.error("[API /organizations/[orgId]/settings GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch settings" }, + { status: 500 }, + ); + } +} + +// PATCH delegates to the resource route by re-exporting its handler. +export { PATCH } from "../route"; diff --git a/app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts b/app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts new file mode 100644 index 000000000..7e23e471a --- /dev/null +++ b/app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts @@ -0,0 +1,44 @@ +/** + * Single SSO provider — DELETE. + * + * Auth: ORG_OWNER. Hard-deletes the row from BetterAuth's `ssoProvider` table. + * The user remains signed in via their existing session — only future SSO + * authentication attempts are affected. + */ + +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string; providerId: string }> }, +) { + try { + const { orgId, providerId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const provider = await prisma.ssoProvider.findUnique({ + where: { id: providerId }, + }); + if (!provider || provider.organizationId !== orgId) { + return NextResponse.json( + { error: "Provider not found for this organization" }, + { status: 404 }, + ); + } + + await prisma.ssoProvider.delete({ where: { id: providerId } }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/sso/providers/[providerId] DELETE] error:", + error, + ); + return NextResponse.json( + { error: "Failed to delete SSO provider" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/sso/providers/route.ts b/app/api/organizations/[orgId]/sso/providers/route.ts new file mode 100644 index 000000000..e387c1801 --- /dev/null +++ b/app/api/organizations/[orgId]/sso/providers/route.ts @@ -0,0 +1,116 @@ +/** + * SSO providers (SAML / OIDC) for an org. + * + * GET — ORG_OWNER. Lists providers registered through BetterAuth. + * POST — ORG_OWNER. Registers a new provider linked to this org. + * + * The actual provider registration with BetterAuth's plugin is handled + * server-side via direct Prisma writes to the auto-generated `ssoProvider` + * table. We tag the row with `organizationId` so the signin domain router in + * middleware.ts can find the right provider for an incoming domain match. + * + * Phase L (SSO admin UI) wires this endpoint into the dashboard form for + * uploading SAML metadata and OIDC client credentials. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +const createProviderSchema = z.object({ + providerId: z + .string() + .trim() + .min(2) + .max(50) + .regex(/^[a-z0-9-]+$/i, "providerId must be alphanumeric"), + domain: z.string().trim().min(3).max(255), + issuer: z.string().trim().min(1).max(500), + samlConfig: z.string().optional(), // SAML metadata XML + oidcConfig: z.string().optional(), // OIDC discovery JSON +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const providers = await prisma.ssoProvider.findMany({ + where: { organizationId: orgId }, + select: { + id: true, + providerId: true, + issuer: true, + domain: true, + }, + }); + + return NextResponse.json({ providers }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/sso/providers GET] error:", + error, + ); + return NextResponse.json( + { error: "Failed to fetch SSO providers" }, + { status: 500 }, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = createProviderSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { providerId, domain, issuer, samlConfig, oidcConfig } = parsed.data; + if (!samlConfig && !oidcConfig) { + return NextResponse.json( + { error: "Either samlConfig (SAML XML) or oidcConfig (OIDC JSON) is required." }, + { status: 400 }, + ); + } + + const provider = await prisma.ssoProvider.create({ + data: { + id: crypto.randomUUID(), + providerId, + domain, + issuer, + organizationId: orgId, + samlConfig: samlConfig ?? null, + oidcConfig: oidcConfig ?? null, + userId: access.session.user.id, + }, + }); + + return NextResponse.json({ provider }, { status: 201 }); + } catch (error) { + console.error( + "[API /organizations/[orgId]/sso/providers POST] error:", + error, + ); + return NextResponse.json( + { error: "Failed to register SSO provider" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/[orgId]/sso/route.ts b/app/api/organizations/[orgId]/sso/route.ts new file mode 100644 index 000000000..6bac7d92b --- /dev/null +++ b/app/api/organizations/[orgId]/sso/route.ts @@ -0,0 +1,105 @@ +/** + * Org SSO settings — GET / PATCH. + * + * GET — ORG_OWNER. Returns OrganizationSSOSettings + the list of providers + * registered through BetterAuth's SSO plugin for this org. + * PATCH — ORG_OWNER. Updates allowedEmailDomains, enforceSSO, and + * defaultRoleForAutoJoin. Provider CRUD is in /sso/providers. + * + * The BetterAuth `ssoProvider` table is queried via Prisma directly. The + * plugin doesn't expose typed list/delete helpers — providers are linked to + * an org via the `organizationId` column the plugin auto-adds. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { OrgMemberRole } from "@prisma/client"; + +const patchSsoSchema = z.object({ + allowedEmailDomains: z.array(z.string().min(3).max(255)).optional(), + enforceSSO: z.boolean().optional(), + defaultRoleForAutoJoin: z.nativeEnum(OrgMemberRole).optional(), +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const settings = await prisma.organizationSSOSettings.findUnique({ + where: { organizationProfileId: access.org.id }, + }); + + const providers = await prisma.ssoProvider.findMany({ + where: { organizationId: orgId }, + select: { + id: true, + providerId: true, + issuer: true, + domain: true, + userId: true, + }, + }); + + return NextResponse.json({ + settings: settings ?? { + allowedEmailDomains: [], + enforceSSO: false, + defaultRoleForAutoJoin: "ORG_LEARNER" as const, + }, + providers, + }); + } catch (error) { + console.error("[API /organizations/[orgId]/sso GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch SSO settings" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + try { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, "ORG_OWNER"); + if (access.error) return access.error; + + const body = await req.json(); + const parsed = patchSsoSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const settings = await prisma.organizationSSOSettings.upsert({ + where: { organizationProfileId: access.org.id }, + create: { + organizationProfileId: access.org.id, + allowedEmailDomains: parsed.data.allowedEmailDomains ?? [], + enforceSSO: parsed.data.enforceSSO ?? false, + defaultRoleForAutoJoin: + parsed.data.defaultRoleForAutoJoin ?? "ORG_LEARNER", + }, + update: parsed.data, + }); + + return NextResponse.json({ settings }); + } catch (error) { + console.error("[API /organizations/[orgId]/sso PATCH] error:", error); + return NextResponse.json( + { error: "Failed to update SSO settings" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/invitations/accept/route.ts b/app/api/organizations/invitations/accept/route.ts new file mode 100644 index 000000000..8d56da841 --- /dev/null +++ b/app/api/organizations/invitations/accept/route.ts @@ -0,0 +1,187 @@ +/** + * Accept an organization invitation. + * + * POST { token } — looks up the Invitation row, verifies it's pending and + * unexpired, and creates the Member + OrganizationMemberProfile rows for the + * authenticated caller. The caller's email must match the invited email. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { OrgMemberRole } from "@prisma/client"; + +const acceptSchema = z.object({ + token: z.string().min(16), +}); + +const PROVIDER_GATED_ROLES: OrgMemberRole[] = ["ORG_CONSULTANT", "ORG_SUPPORT"]; + +function coerceOrgMemberRole(raw: string): OrgMemberRole { + // Defensive: invitations created via BetterAuth's UI may carry "member" / + // "owner" / "admin" string values. Map them onto our typed enum. + if (raw in OrgMemberRole) return raw as OrgMemberRole; + switch (raw.toLowerCase()) { + case "owner": + return "ORG_OWNER"; + case "admin": + return "ORG_ADMIN"; + case "manager": + return "ORG_MANAGER"; + case "member": + case "learner": + default: + return "ORG_LEARNER"; + } +} + +export async function POST(req: NextRequest) { + try { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const body = await req.json(); + const parsed = acceptSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const invitation = await prisma.invitation.findUnique({ + where: { id: parsed.data.token }, + include: { + organization: { + select: { id: true, name: true, organizationProfile: true }, + }, + }, + }); + + if (!invitation) { + return NextResponse.json( + { error: "Invitation not found" }, + { status: 404 }, + ); + } + if (invitation.status !== "pending") { + return NextResponse.json( + { error: `Invitation is ${invitation.status}` }, + { status: 400 }, + ); + } + if (invitation.expiresAt < new Date()) { + return NextResponse.json( + { error: "Invitation has expired" }, + { status: 400 }, + ); + } + if ( + invitation.email.toLowerCase() !== + auth.session.user.email.toLowerCase() + ) { + return NextResponse.json( + { error: "This invitation was sent to a different email address." }, + { status: 403 }, + ); + } + if (!invitation.organization.organizationProfile) { + return NextResponse.json( + { error: "Organization profile is missing — contact support." }, + { status: 500 }, + ); + } + + const role = coerceOrgMemberRole(invitation.role); + if (!ENABLE_PROVIDER_ORGS && PROVIDER_GATED_ROLES.includes(role)) { + return NextResponse.json( + { + error: `${role} role is gated behind PROVIDER orgs.`, + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + const orgProfile = invitation.organization.organizationProfile; + const userId = auth.session.user.id; + + // Idempotent: if a Member already exists, surface that. + const existing = await prisma.member.findUnique({ + where: { + organizationId_userId: { + organizationId: invitation.organizationId, + userId, + }, + }, + }); + if (existing) { + await prisma.invitation.update({ + where: { id: invitation.id }, + data: { status: "accepted" }, + }); + return NextResponse.json({ + organization: { + id: invitation.organization.id, + name: invitation.organization.name, + }, + alreadyMember: true, + }); + } + + const consulteeProfileId = auth.session.user.consulteeProfileId ?? null; + const consultantProfileId = auth.session.user.consultantProfileId ?? null; + + await prisma.$transaction(async (tx) => { + const member = await tx.member.create({ + data: { + organizationId: invitation.organizationId, + userId, + role, + }, + }); + + await tx.organizationMemberProfile.create({ + data: { + memberId: member.id, + organizationProfileId: orgProfile.id, + role, + status: "ACTIVE", + consulteeProfileId: + role === "ORG_LEARNER" ? consulteeProfileId : null, + consultantProfileId: + role === "ORG_CONSULTANT" ? consultantProfileId : null, + seatAssignedAt: role === "ORG_LEARNER" ? new Date() : null, + }, + }); + + if (role === "ORG_LEARNER") { + await tx.organizationProfile.update({ + where: { id: orgProfile.id }, + data: { seatsUsed: { increment: 1 } }, + }); + } + + await tx.invitation.update({ + where: { id: invitation.id }, + data: { status: "accepted" }, + }); + }); + + return NextResponse.json({ + organization: { + id: invitation.organization.id, + name: invitation.organization.name, + }, + role, + }); + } catch (error) { + console.error("[API /organizations/invitations/accept POST] error:", error); + return NextResponse.json( + { error: "Failed to accept invitation" }, + { status: 500 }, + ); + } +} diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts new file mode 100644 index 000000000..a4ff0eb2d --- /dev/null +++ b/app/api/organizations/route.ts @@ -0,0 +1,299 @@ +/** + * Organizations API — top-level collection. + * + * GET — list the caller's orgs (ADMIN sees all) + * POST — create a new organization + profile + owner membership atomically + * + * PROVIDER orgs are gated by the ENABLE_PROVIDER_ORGS feature flag. When the + * flag is off, a POST with `kind === "PROVIDER"` (or `HYBRID`) is rejected + * with 501. See lib/feature-flags.ts and Issue #646. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import { ENABLE_PROVIDER_ORGS } from "@/lib/feature-flags"; +import { + OrganizationKind, + OrganizationBillingMode, + OrgSizeBucket, +} from "@prisma/client"; + +const createOrgSchema = z.object({ + name: z.string().trim().min(2).max(100), + slug: z + .string() + .trim() + .regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens") + .min(2) + .max(50) + .optional(), + kind: z.nativeEnum(OrganizationKind).default(OrganizationKind.BUYER), + billingMode: z + .nativeEnum(OrganizationBillingMode) + .default(OrganizationBillingMode.TAG_ONLY), + billingEmail: z.string().email(), + description: z.string().max(2000).optional(), + industry: z.string().max(100).optional(), + sizeBucket: z.nativeEnum(OrgSizeBucket).optional(), + website: z.string().url().optional(), + logo: z.string().url().optional(), +}); + +/** + * Slugify a name into a URL-safe string. Collisions resolved at insert time + * by appending a short random suffix on unique-constraint failure. + */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +/** + * GET /api/organizations + * Lists the caller's active org memberships. Platform ADMINs see every org. + */ +export async function GET() { + try { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const userId = auth.session.user.id; + + if (auth.session.user.role === "ADMIN") { + const orgs = await prisma.organizationProfile.findMany({ + include: { + organization: { + select: { id: true, name: true, slug: true, logo: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json({ + organizations: orgs.map((o) => ({ + id: o.organization.id, + profileId: o.id, + name: o.organization.name, + slug: o.organization.slug, + logo: o.organization.logo, + kind: o.kind, + status: o.status, + billingMode: o.billingMode, + role: "ORG_OWNER" as const, + isPlatformAdmin: true, + })), + }); + } + + const memberships = await prisma.organizationMemberProfile.findMany({ + where: { + status: "ACTIVE", + member: { userId }, + }, + include: { + organizationProfile: { + include: { + organization: { + select: { id: true, name: true, slug: true, logo: true }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ + organizations: memberships + .filter((m) => m.organizationProfile.status !== "DEACTIVATED") + .map((m) => ({ + id: m.organizationProfile.organization.id, + profileId: m.organizationProfileId, + name: m.organizationProfile.organization.name, + slug: m.organizationProfile.organization.slug, + logo: m.organizationProfile.organization.logo, + kind: m.organizationProfile.kind, + status: m.organizationProfile.status, + billingMode: m.organizationProfile.billingMode, + role: m.role, + isPlatformAdmin: false, + })), + }); + } catch (error) { + console.error("[API /organizations GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch organizations" }, + { status: 500 }, + ); + } +} + +/** + * POST /api/organizations + * Creates a new organization with the caller as ORG_OWNER. All four rows + * (Organization, OrganizationProfile, Member, OrganizationMemberProfile) are + * inserted in a single transaction. PROVIDER and HYBRID kinds are rejected + * with 501 unless ENABLE_PROVIDER_ORGS is set. + */ +export async function POST(req: NextRequest) { + try { + const auth = await requireApiAuth(); + if (auth.error) return auth.error; + + const body = await req.json(); + const parsed = createOrgSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { + name, + slug: slugInput, + kind, + billingMode, + billingEmail, + description, + industry, + sizeBucket, + website, + logo, + } = parsed.data; + + // Feature-flag gate for PROVIDER-adjacent kinds. + if ( + !ENABLE_PROVIDER_ORGS && + (kind === "PROVIDER" || kind === "HYBRID") + ) { + return NextResponse.json( + { + error: + "PROVIDER organizations are not yet available. Contact support if you represent a consultant agency.", + flag: "ENABLE_PROVIDER_ORGS", + }, + { status: 501 }, + ); + } + + // Enforce the 5-org creation limit to mirror BetterAuth's organizationLimit. + const ownedCount = await prisma.member.count({ + where: { + userId: auth.session.user.id, + role: "ORG_OWNER", + }, + }); + if (ownedCount >= 5) { + return NextResponse.json( + { error: "You have reached the maximum number of owned organizations (5)." }, + { status: 403 }, + ); + } + + // Generate slug from name if not supplied; retry once with a random + // suffix if the initial insert collides on the unique constraint. + const baseSlug = slugInput ?? slugify(name); + if (!baseSlug) { + return NextResponse.json( + { error: "Could not derive a valid slug from the organization name" }, + { status: 400 }, + ); + } + + const runTransaction = (slug: string) => + prisma.$transaction(async (tx) => { + const organization = await tx.organization.create({ + data: { name, slug, logo: logo ?? null }, + }); + + const profile = await tx.organizationProfile.create({ + data: { + organizationId: organization.id, + kind, + billingMode, + billingEmail, + description: description ?? null, + industry: industry ?? null, + sizeBucket: sizeBucket ?? null, + website: website ?? null, + logo: logo ?? null, + status: "ACTIVE", + }, + }); + + const member = await tx.member.create({ + data: { + organizationId: organization.id, + userId: auth.session.user.id, + role: "ORG_OWNER", + }, + }); + + await tx.organizationMemberProfile.create({ + data: { + memberId: member.id, + organizationProfileId: profile.id, + role: "ORG_OWNER", + status: "ACTIVE", + }, + }); + + // SEAT_PACK orgs get a zeroed credit pool created up front so the + // dashboard can render without a null check. + if (billingMode === "SEAT_PACK") { + await tx.orgCreditPool.create({ + data: { + organizationProfileId: profile.id, + balance: 0, + totalPurchased: 0, + }, + }); + } + + return { organization, profile }; + }); + + let result: Awaited>; + try { + result = await runTransaction(baseSlug); + } catch (error) { + // P2002 = unique constraint violation (most likely: slug collision) + const code = (error as { code?: string })?.code; + if (code === "P2002") { + const fallbackSlug = `${baseSlug}-${Math.random().toString(36).slice(2, 8)}`; + result = await runTransaction(fallbackSlug); + } else { + throw error; + } + } + + return NextResponse.json( + { + organization: { + id: result.organization.id, + name: result.organization.name, + slug: result.organization.slug, + logo: result.organization.logo, + }, + profile: { + id: result.profile.id, + kind: result.profile.kind, + status: result.profile.status, + billingMode: result.profile.billingMode, + }, + }, + { status: 201 }, + ); + } catch (error) { + console.error("[API /organizations POST] error:", error); + return NextResponse.json( + { error: "Failed to create organization" }, + { status: 500 }, + ); + } +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx index 3c7ab7557..364ae5dc1 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx @@ -296,6 +296,7 @@ export function EventPlannerForClass({ topics: formData.topics, consultantProfileId: consultantId, consultantProfile: null, + organizationProfileId: null, certificateProvided: formData.certificateProvided ?? false, recordingEnabled: formData.recordingEnabled ?? false, recordingStoragePolicy: initialData?.classPlan?.recordingStoragePolicy ?? "STREAM_ONLY", diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx index 4411fa4c2..0015e498a 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx @@ -291,6 +291,7 @@ export function EventPlannerForWebinar({ topics: formData.topics, consultantProfileId: consultantId, consultantProfile: null, + organizationProfileId: null, imageUrl: initialData?.webinarPlan?.imageUrl ?? null, createdAt: initialData?.webinarPlan?.createdAt ?? now, updatedAt: now, From 6c4ac7b5e0929d678d0171750cb64a7df1eadc5f Mon Sep 17 00:00:00 2001 From: teetangh Date: Fri, 10 Apr 2026 06:35:56 +0530 Subject: [PATCH 005/415] feat(dashboard/organization): scaffold org dashboard pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../organization/[orgId]/analytics/page.tsx | 123 ++++++ .../organization/[orgId]/billing/page.tsx | 270 ++++++++++++ .../organization/[orgId]/consultants/page.tsx | 163 +++++++ .../organization/[orgId]/credits/page.tsx | 240 +++++++++++ .../organization/[orgId]/home/page.tsx | 180 ++++++++ .../organization/[orgId]/invitations/page.tsx | 283 ++++++++++++ app/dashboard/organization/[orgId]/layout.tsx | 205 +++++++++ .../organization/[orgId]/learners/page.tsx | 130 ++++++ .../organization/[orgId]/members/page.tsx | 278 ++++++++++++ app/dashboard/organization/[orgId]/page.tsx | 13 + .../organization/[orgId]/payouts/page.tsx | 82 ++++ .../organization/[orgId]/plans/page.tsx | 291 +++++++++++++ .../organization/[orgId]/settings/page.tsx | 275 ++++++++++++ .../[orgId]/settings/sso/page.tsx | 406 ++++++++++++++++++ app/dashboard/organization/page.tsx | 266 ++++++++++++ 15 files changed, 3205 insertions(+) create mode 100644 app/dashboard/organization/[orgId]/analytics/page.tsx create mode 100644 app/dashboard/organization/[orgId]/billing/page.tsx create mode 100644 app/dashboard/organization/[orgId]/consultants/page.tsx create mode 100644 app/dashboard/organization/[orgId]/credits/page.tsx create mode 100644 app/dashboard/organization/[orgId]/home/page.tsx create mode 100644 app/dashboard/organization/[orgId]/invitations/page.tsx create mode 100644 app/dashboard/organization/[orgId]/layout.tsx create mode 100644 app/dashboard/organization/[orgId]/learners/page.tsx create mode 100644 app/dashboard/organization/[orgId]/members/page.tsx create mode 100644 app/dashboard/organization/[orgId]/page.tsx create mode 100644 app/dashboard/organization/[orgId]/payouts/page.tsx create mode 100644 app/dashboard/organization/[orgId]/plans/page.tsx create mode 100644 app/dashboard/organization/[orgId]/settings/page.tsx create mode 100644 app/dashboard/organization/[orgId]/settings/sso/page.tsx create mode 100644 app/dashboard/organization/page.tsx diff --git a/app/dashboard/organization/[orgId]/analytics/page.tsx b/app/dashboard/organization/[orgId]/analytics/page.tsx new file mode 100644 index 000000000..b14ceb9e4 --- /dev/null +++ b/app/dashboard/organization/[orgId]/analytics/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Users, + Briefcase, + Calendar, + CreditCard, + GraduationCap, + TrendingUp, +} from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, + DashboardGrid, +} from "@/components/dashboard/DashboardShell"; +import { StatCard, StatCardSkeleton } from "@/components/dashboard/StatCard"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +interface OrgAnalytics { + members: { total: number; learners: number }; + plans: { active: number }; + bookings: { + monthToDate: number; + lastMonth: number; + deltaPct: number | null; + }; + revenue: { monthToDateGross: number }; + seatsTotal: number | null; + seatsUsed: number; +} + +async function fetchAnalytics(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/analytics`); + if (!res.ok) throw new Error("Failed to load analytics"); + return res.json(); +} + +export default function OrgAnalyticsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const { data, isLoading } = useQuery({ + queryKey: ["org-analytics", orgId], + queryFn: () => fetchAnalytics(orgId), + }); + + if (isLoading) { + return ( + <> + + + + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + + + ); + } + + return ( + <> + + + + + + + = 0, + } + : undefined + } + /> + + + + + + ); +} diff --git a/app/dashboard/organization/[orgId]/billing/page.tsx b/app/dashboard/organization/[orgId]/billing/page.tsx new file mode 100644 index 000000000..fcca4010b --- /dev/null +++ b/app/dashboard/organization/[orgId]/billing/page.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { use } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CreditCard, AlertCircle, Sparkles } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, + DashboardGrid, +} from "@/components/dashboard/DashboardShell"; +import { StatCard } from "@/components/dashboard/StatCard"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +interface BillingSummary { + billingMode: "TAG_ONLY" | "SEAT_PACK" | "INVOICED_MONTHLY"; + monthToDate: { gross: number; paymentCount: number }; + outstanding: { amount: number; invoiceCount: number }; + pendingCharges: { amount: number; paymentCount: number } | null; + paymentTermsDays: number; +} + +interface OrgInvoice { + id: string; + invoiceNumber: string; + amount: number; + currency: string; + status: "DRAFT" | "SENT" | "PAID" | "OVERDUE" | "CANCELLED"; + dueDate: string | null; + paidAt: string | null; + autoGenerated: boolean; + createdAt: string; +} + +async function fetchBilling(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/billing`); + if (!res.ok) throw new Error("Failed to load billing summary"); + return res.json(); +} + +async function fetchInvoices( + orgId: string, +): Promise<{ invoices: OrgInvoice[] }> { + const res = await fetch(`/api/organizations/${orgId}/billing/invoices?limit=50`); + if (!res.ok) throw new Error("Failed to load invoices"); + return res.json(); +} + +async function generateInvoice(orgId: string) { + const res = await fetch( + `/api/organizations/${orgId}/billing/generate-invoice`, + { method: "POST" }, + ); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to generate invoice"); + return body; +} + +async function payInvoice(orgId: string, invoiceId: string) { + const res = await fetch( + `/api/organizations/${orgId}/billing/invoices/${invoiceId}/pay`, + { method: "POST" }, + ); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to initiate payment"); + return body; +} + +export default function OrgBillingPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + + const summary = useQuery({ + queryKey: ["org-billing", orgId], + queryFn: () => fetchBilling(orgId), + }); + const invoices = useQuery({ + queryKey: ["org-billing-invoices", orgId], + queryFn: () => fetchInvoices(orgId), + }); + + const generateMutation = useMutation({ + mutationFn: () => generateInvoice(orgId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-billing", orgId] }); + queryClient.invalidateQueries({ + queryKey: ["org-billing-invoices", orgId], + }); + }, + }); + + const payMutation = useMutation({ + mutationFn: (invoiceId: string) => payInvoice(orgId, invoiceId), + onSuccess: (result: { pendingPhaseK?: boolean; message?: string }) => { + if (result.pendingPhaseK) { + alert(result.message ?? "Gateway integration coming soon."); + } + queryClient.invalidateQueries({ + queryKey: ["org-billing-invoices", orgId], + }); + }, + }); + + return ( + <> + generateMutation.mutate()} + disabled={generateMutation.isPending} + > + Generate invoice + + ) + } + /> + + {summary.isLoading ? ( +

Loading…

+ ) : ( + + + 0 + ? "warning" + : "default" + } + /> + {summary.data?.pendingCharges && ( + + )} + + + )} + + + + Invoices + + Manual + auto-generated invoices for this organization. + + + + {invoices.isLoading ? ( +

Loading…

+ ) : ( + + + + Number + Amount + Status + Due + + + + + {invoices.data?.invoices.map((inv) => ( + + + {inv.invoiceNumber} + + + {formatCurrencyAmount(inv.amount, inv.currency)} + + + + {inv.status} + + + + {inv.dueDate + ? new Date(inv.dueDate).toLocaleDateString() + : "—"} + + + {(inv.status === "SENT" || + inv.status === "OVERDUE") && ( + + )} + + + ))} + {invoices.data && invoices.data.invoices.length === 0 && ( + + + No invoices yet. + + + )} + +
+ )} +
+
+
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/consultants/page.tsx b/app/dashboard/organization/[orgId]/consultants/page.tsx new file mode 100644 index 000000000..c48f14277 --- /dev/null +++ b/app/dashboard/organization/[orgId]/consultants/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Lock } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface ConsultantRow { + id: string; + status: string; + consultantProfile: { + id: string; + headline: string | null; + rating: number; + isVerified: boolean; + } | null; + member: { + user: { id: string; name: string | null; email: string }; + }; +} + +interface ConsultantsResponse { + consultants?: ConsultantRow[]; + error?: string; + flag?: string; +} + +async function fetchConsultants(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/consultants`); + // 501 → return shape so the gated panel can render the "feature locked" UI. + if (res.status === 501) return res.json(); + if (!res.ok) throw new Error("Failed to load consultants"); + return res.json(); +} + +export default function OrgConsultantsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + + const { data, isLoading } = useQuery({ + queryKey: ["org-consultants", orgId], + queryFn: () => fetchConsultants(orgId), + }); + + const isGated = !!data?.flag; + + return ( + <> + + + {isGated ? ( + + +
+ + Provider tier required +
+ + Consultant agencies are part of the upcoming Provider tier. + Contact us to enable it for your organization. + +
+
+ ) : ( + + + + {isLoading + ? "Loading…" + : `${data?.consultants?.length ?? 0} consultants`} + + + + {isLoading ? ( +

Loading…

+ ) : ( + + + + Consultant + Headline + Rating + Verified + + + + {data?.consultants?.map((c) => ( + + +
+ + {c.member.user.name ?? "—"} + + + {c.member.user.email} + +
+
+ + {c.consultantProfile?.headline ?? "—"} + + + {c.consultantProfile?.rating?.toFixed(1) ?? "—"} + + + + {c.consultantProfile?.isVerified ? "Yes" : "No"} + + +
+ ))} + {data?.consultants && data.consultants.length === 0 && ( + + + No consultants yet. + + + )} +
+
+ )} +
+
+ )} +
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/credits/page.tsx b/app/dashboard/organization/[orgId]/credits/page.tsx new file mode 100644 index 000000000..14a334479 --- /dev/null +++ b/app/dashboard/organization/[orgId]/credits/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { use, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Coins, Plus } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, + DashboardGrid, +} from "@/components/dashboard/DashboardShell"; +import { StatCard } from "@/components/dashboard/StatCard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +interface CreditsResponse { + billingMode: string; + pool: { + balance: number; + totalPurchased: number; + currency: string; + } | null; + ledger: Array<{ + id: string; + delta: number; + reason: string; + balanceAfter: number; + createdAt: string; + }>; +} + +async function fetchCredits(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/credits`); + if (!res.ok) throw new Error("Failed to load credits"); + return res.json(); +} + +async function purchaseCredits(orgId: string, amountPaise: number) { + const res = await fetch(`/api/organizations/${orgId}/credits/purchase`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amountPaise }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to purchase credits"); + return body; +} + +export default function OrgCreditsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ["org-credits", orgId], + queryFn: () => fetchCredits(orgId), + }); + + const [showBuy, setShowBuy] = useState(false); + const [amountMajor, setAmountMajor] = useState("1000"); + + const purchaseMutation = useMutation({ + mutationFn: () => + purchaseCredits(orgId, Math.round(parseFloat(amountMajor || "0") * 100)), + onSuccess: (result: { pendingPhaseJ?: boolean; message?: string }) => { + if (result.pendingPhaseJ) { + alert(result.message ?? "Gateway integration coming soon."); + } + setShowBuy(false); + queryClient.invalidateQueries({ queryKey: ["org-credits", orgId] }); + }, + }); + + const wrongMode = data && data.billingMode !== "SEAT_PACK"; + + return ( + <> + setShowBuy(true)}> + Buy credits + + ) + } + /> + + {wrongMode ? ( + + + Not on Seat Pack billing + + This page is only relevant when the org's billing mode is + SEAT_PACK. Switch the billing mode in Settings to enable + credit pool management. + + + + ) : isLoading ? ( +

Loading…

+ ) : ( + <> + + + + + + + + Recent activity + + + + + + When + Reason + Δ + Balance + + + + {data?.ledger.map((row) => ( + + + {new Date(row.createdAt).toLocaleString()} + + {row.reason} + = 0 ? "text-emerald-600" : "text-red-600" + }`} + > + {row.delta >= 0 ? "+" : ""} + {formatCurrencyAmount(row.delta, "INR")} + + + {formatCurrencyAmount(row.balanceAfter, "INR")} + + + ))} + {data && data.ledger.length === 0 && ( + + + No activity yet. + + + )} + +
+
+
+ + )} +
+ + + + + Buy credits + +
+
+ + setAmountMajor(e.target.value)} + /> +

+ You will be redirected to the payment gateway to complete the + purchase. +

+
+
+ + + + +
+
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/home/page.tsx b/app/dashboard/organization/[orgId]/home/page.tsx new file mode 100644 index 000000000..bf752fbef --- /dev/null +++ b/app/dashboard/organization/[orgId]/home/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Users, + GraduationCap, + Briefcase, + CreditCard, + AlertCircle, +} from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, + DashboardGrid, +} from "@/components/dashboard/DashboardShell"; +import { StatCard, StatCardSkeleton } from "@/components/dashboard/StatCard"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +interface OrgAnalytics { + members: { total: number; learners: number }; + plans: { active: number }; + bookings: { + monthToDate: number; + lastMonth: number; + deltaPct: number | null; + }; + revenue: { monthToDateGross: number }; + seatsTotal: number | null; + seatsUsed: number; +} + +interface OrgBilling { + billingMode: "TAG_ONLY" | "SEAT_PACK" | "INVOICED_MONTHLY"; + outstanding: { amount: number; invoiceCount: number }; + pendingCharges: { amount: number; paymentCount: number } | null; + creditPool: { balance: number; totalPurchased: number } | null; +} + +async function fetchAnalytics(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/analytics`); + if (!res.ok) throw new Error("Failed to load analytics"); + return res.json(); +} + +async function fetchBilling(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/billing`); + if (!res.ok) throw new Error("Failed to load billing"); + return res.json(); +} + +export default function OrgHomePage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + + const analytics = useQuery({ + queryKey: ["org-analytics", orgId], + queryFn: () => fetchAnalytics(orgId), + }); + const billing = useQuery({ + queryKey: ["org-billing", orgId], + queryFn: () => fetchBilling(orgId), + }); + + const isLoading = analytics.isLoading || billing.isLoading; + + return ( + <> + + + {isLoading ? ( + + {[1, 2, 3, 4].map((i) => ( + + ))} + + ) : ( + + + + + = 0, + } + : undefined + } + /> + + )} + + {!isLoading && billing.data && ( +
+ + {billing.data.billingMode === "INVOICED_MONTHLY" && + billing.data.pendingCharges && ( + + )} + {billing.data.billingMode === "SEAT_PACK" && + billing.data.creditPool && ( + + )} + 0 + ? "warning" + : "default" + } + /> + +
+ )} +
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/invitations/page.tsx b/app/dashboard/organization/[orgId]/invitations/page.tsx new file mode 100644 index 000000000..0e15e4dd1 --- /dev/null +++ b/app/dashboard/organization/[orgId]/invitations/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { use, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Mail, Trash2, Copy } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface Invitation { + id: string; + email: string; + role: string; + status: string; + expiresAt: string; + createdAt: string; + inviter: { id: string; name: string | null; email: string }; +} + +const ROLE_OPTIONS = [ + { value: "ORG_LEARNER", label: "Learner" }, + { value: "ORG_MANAGER", label: "Manager" }, + { value: "ORG_ADMIN", label: "Admin" }, + { value: "ORG_OWNER", label: "Owner" }, +]; + +async function fetchInvitations( + orgId: string, +): Promise<{ invitations: Invitation[] }> { + const res = await fetch(`/api/organizations/${orgId}/invitations`); + if (!res.ok) throw new Error("Failed to load invitations"); + return res.json(); +} + +async function createInvitation( + orgId: string, + payload: { email: string; role: string }, +) { + const res = await fetch(`/api/organizations/${orgId}/invitations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to create invitation"); + return body; +} + +async function revokeInvitation(orgId: string, invitationId: string) { + const res = await fetch( + `/api/organizations/${orgId}/invitations/${invitationId}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to revoke invitation"); + } +} + +export default function OrgInvitationsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["org-invitations", orgId], + queryFn: () => fetchInvitations(orgId), + }); + + const [showCreate, setShowCreate] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("ORG_LEARNER"); + const [error, setError] = useState(null); + + const createMutation = useMutation({ + mutationFn: () => createInvitation(orgId, { email: email.trim(), role }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-invitations", orgId] }); + setShowCreate(false); + setEmail(""); + setError(null); + }, + onError: (err: Error) => setError(err.message), + }); + + const revokeMutation = useMutation({ + mutationFn: (id: string) => revokeInvitation(orgId, id), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["org-invitations", orgId] }), + }); + + const copyInviteLink = (invitationId: string) => { + const url = `${window.location.origin}/organizations/invite/${invitationId}`; + navigator.clipboard.writeText(url); + }; + + return ( + <> + setShowCreate(true)}> + Invite by email + + } + /> + + + + + {isLoading + ? "Loading…" + : `${data?.invitations.length ?? 0} invitations`} + + + + {isLoading ? ( +

Loading…

+ ) : ( + + + + Email + Role + Status + Expires + Actions + + + + {data?.invitations.map((inv) => ( + + {inv.email} + + {inv.role} + + + + {inv.status} + + + + {new Date(inv.expiresAt).toLocaleDateString()} + + +
+ + {inv.status === "pending" && ( + + )} +
+
+
+ ))} + {data && data.invitations.length === 0 && ( + + + No pending invitations. + + + )} +
+
+ )} +
+
+
+ + + + + Send invitation + + The recipient does not need an account yet — they will create + one when accepting the invite. + + + +
+
+ + setEmail(e.target.value)} + placeholder="alice@acme.com" + /> +
+
+ + +
+ {error &&

{error}

} +
+ + + + + +
+
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/layout.tsx b/app/dashboard/organization/[orgId]/layout.tsx new file mode 100644 index 000000000..21e052a02 --- /dev/null +++ b/app/dashboard/organization/[orgId]/layout.tsx @@ -0,0 +1,205 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { use, useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { + Home, + Users, + Mail, + GraduationCap, + Briefcase, + CreditCard, + Coins, + BarChart3, + Settings, + Wallet, + UserCog, + type LucideIcon, +} from "lucide-react"; + +import { + CollapsibleSidebar, + CollapsibleSidebarSkeleton, + type CollapsibleSidebarItem, +} from "@/components/dashboard/CollapsibleSidebar"; +import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; +import { signOut, useSession } from "@/lib/auth-client"; +import { disconnectStreamClients } from "@/providers/StreamProvider"; + +interface OrgDetailsResponse { + organization: { id: string; name: string; slug: string; logo: string | null }; + profile: { + id: string; + kind: "BUYER" | "PROVIDER" | "HYBRID"; + status: string; + billingMode: "TAG_ONLY" | "SEAT_PACK" | "INVOICED_MONTHLY"; + }; + membership: { role: string; status: string }; +} + +async function fetchOrg(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to load organization"); + } + return res.json(); +} + +function AccessDenied({ title, message }: { title: string; message: string }) { + return ( +
+ +

{title}

+

{message}

+ + Back to organizations + +
+
+ ); +} + +export default function OrgLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const pathname = usePathname(); + const router = useRouter(); + const { data: session, isPending: isSessionLoading } = useSession(); + + const { + data: org, + error, + isLoading, + } = useQuery({ + queryKey: ["organization", orgId], + queryFn: () => fetchOrg(orgId), + enabled: !!orgId && !!session?.user?.id, + staleTime: 60_000, + }); + + // Compute the sidebar items based on org kind + role. + // Note: PROVIDER feature flag is enforced at the API layer, so the + // consultants/payouts links here just self-hide for BUYER orgs. + const sidebarItems: CollapsibleSidebarItem[] = useMemo(() => { + if (!org) return []; + const isProviderOrHybrid = + org.profile.kind === "PROVIDER" || org.profile.kind === "HYBRID"; + const isBuyerOrHybrid = + org.profile.kind === "BUYER" || org.profile.kind === "HYBRID"; + + const items: { name: string; icon: LucideIcon; path: string; show?: boolean }[] = [ + { name: "Overview", icon: Home, path: "home" }, + { name: "Members", icon: Users, path: "members" }, + { name: "Invitations", icon: Mail, path: "invitations" }, + { name: "Learners", icon: GraduationCap, path: "learners", show: isBuyerOrHybrid }, + { name: "Consultants", icon: UserCog, path: "consultants", show: isProviderOrHybrid }, + { name: "Plans", icon: Briefcase, path: "plans" }, + { + name: "Credits", + icon: Coins, + path: "credits", + show: org.profile.billingMode === "SEAT_PACK", + }, + { name: "Billing", icon: CreditCard, path: "billing" }, + { + name: "Payouts", + icon: Wallet, + path: "payouts", + show: isProviderOrHybrid, + }, + { name: "Analytics", icon: BarChart3, path: "analytics" }, + { name: "Settings", icon: Settings, path: "settings" }, + ]; + + return items + .filter((it) => it.show !== false) + .map(({ show: _show, ...rest }) => rest); + }, [org]); + + // Redirect to /home when landing on the bare /[orgId] route. + useEffect(() => { + if (org && pathname === `/dashboard/organization/${orgId}`) { + router.replace(`/dashboard/organization/${orgId}/home`); + } + }, [org, pathname, orgId, router]); + + const handleSignOut = async () => { + try { + await disconnectStreamClients(); + } catch { + // ignore + } + signOut({ + fetchOptions: { + onSuccess: () => { + window.location.href = "/auth/signin"; + }, + }, + }); + }; + + if (!session?.user?.id && !isSessionLoading) { + return ( + + ); + } + + if ((isLoading || isSessionLoading) && !org) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+ + +
+
+ {children} +
+
+
+ ); +} diff --git a/app/dashboard/organization/[orgId]/learners/page.tsx b/app/dashboard/organization/[orgId]/learners/page.tsx new file mode 100644 index 000000000..c8d85cc6f --- /dev/null +++ b/app/dashboard/organization/[orgId]/learners/page.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface Learner { + id: string; + status: string; + seatAssignedAt: string | null; + user: { + id: string; + name: string | null; + email: string; + image: string | null; + }; +} + +async function fetchLearners( + orgId: string, +): Promise<{ learners: Learner[]; total: number }> { + const res = await fetch(`/api/organizations/${orgId}/learners?limit=100`); + if (!res.ok) throw new Error("Failed to load learners"); + return res.json(); +} + +export default function OrgLearnersPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + + const { data, isLoading } = useQuery({ + queryKey: ["org-learners", orgId], + queryFn: () => fetchLearners(orgId), + }); + + return ( + <> + + + + + + {isLoading ? "Loading…" : `${data?.total ?? 0} learners`} + + + + {isLoading ? ( +

Loading…

+ ) : ( + + + + Learner + Status + Seat assigned + + + + {data?.learners.map((l) => ( + + +
+ + {l.user.name ?? "—"} + + + {l.user.email} + +
+
+ + + {l.status} + + + + {l.seatAssignedAt + ? new Date(l.seatAssignedAt).toLocaleDateString() + : "—"} + +
+ ))} + {data && data.learners.length === 0 && ( + + + No learners yet. Invite some via the Invitations page. + + + )} +
+
+ )} +
+
+
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/members/page.tsx b/app/dashboard/organization/[orgId]/members/page.tsx new file mode 100644 index 000000000..22fa07b5a --- /dev/null +++ b/app/dashboard/organization/[orgId]/members/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { use, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { UserPlus, Trash2 } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface MemberRow { + id: string; + memberId: string; + role: string; + status: string; + createdAt: string; + user: { + id: string; + name: string | null; + email: string; + image: string | null; + }; +} + +const SELECTABLE_ROLES = [ + { value: "ORG_OWNER", label: "Owner" }, + { value: "ORG_ADMIN", label: "Admin" }, + { value: "ORG_MANAGER", label: "Manager" }, + { value: "ORG_LEARNER", label: "Learner" }, +]; + +async function fetchMembers(orgId: string): Promise<{ members: MemberRow[] }> { + const res = await fetch(`/api/organizations/${orgId}/members`); + if (!res.ok) throw new Error("Failed to load members"); + return res.json(); +} + +async function addMember( + orgId: string, + payload: { email: string; role: string }, +) { + const res = await fetch(`/api/organizations/${orgId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to add member"); + return body; +} + +async function removeMember(orgId: string, memberId: string) { + const res = await fetch( + `/api/organizations/${orgId}/members/${memberId}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to remove member"); + } +} + +export default function OrgMembersPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["org-members", orgId], + queryFn: () => fetchMembers(orgId), + }); + + const [showInvite, setShowInvite] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("ORG_LEARNER"); + const [error, setError] = useState(null); + + const addMutation = useMutation({ + mutationFn: () => addMember(orgId, { email: email.trim(), role }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-members", orgId] }); + setShowInvite(false); + setEmail(""); + setRole("ORG_LEARNER"); + setError(null); + }, + onError: (err: Error) => setError(err.message), + }); + + const removeMutation = useMutation({ + mutationFn: (memberId: string) => removeMember(orgId, memberId), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["org-members", orgId] }), + }); + + return ( + <> + setShowInvite(true)}> + Add member + + } + /> + + + + + {isLoading + ? "Loading…" + : `${data?.members.length ?? 0} members`} + + + + {isLoading ? ( +

Loading…

+ ) : ( + + + + Member + Role + Status + + + + + {data?.members.map((m) => ( + + +
+ + {m.user.name ?? "—"} + + + {m.user.email} + +
+
+ + {m.role} + + + + {m.status} + + + + + +
+ ))} + {data && data.members.length === 0 && ( + + + No members yet. + + + )} +
+
+ )} +
+
+
+ + + + + Add member + + The user must already have a Familiarise account. To invite a + brand-new email, use the Invitations page. + + + +
+
+ + setEmail(e.target.value)} + placeholder="alice@acme.com" + /> +
+
+ + +
+ {error &&

{error}

} +
+ + + + + +
+
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/page.tsx b/app/dashboard/organization/[orgId]/page.tsx new file mode 100644 index 000000000..c3f9cf9fa --- /dev/null +++ b/app/dashboard/organization/[orgId]/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +/** + * Bare /[orgId] route — server-side redirect to /home so the URL stays clean. + */ +export default async function OrgRoot({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + redirect(`/dashboard/organization/${orgId}/home`); +} diff --git a/app/dashboard/organization/[orgId]/payouts/page.tsx b/app/dashboard/organization/[orgId]/payouts/page.tsx new file mode 100644 index 000000000..e39971b8f --- /dev/null +++ b/app/dashboard/organization/[orgId]/payouts/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Lock } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface PayoutsResponse { + payouts?: Array<{ + id: string; + amount: number; + currency: string; + status: string; + periodStart: string; + periodEnd: string; + }>; + error?: string; + flag?: string; +} + +async function fetchPayouts(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/payouts`); + if (res.status === 501) return res.json(); + if (!res.ok) throw new Error("Failed to load payouts"); + return res.json(); +} + +export default function OrgPayoutsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const { data } = useQuery({ + queryKey: ["org-payouts", orgId], + queryFn: () => fetchPayouts(orgId), + }); + + const isGated = !!data?.flag; + + return ( + <> + + + {isGated ? ( + + +
+ + Provider tier required +
+ + Payouts are available once your organization joins the + Provider tier. Contact us to enable it. + +
+
+ ) : ( + + + No payouts yet. + + + )} +
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/plans/page.tsx b/app/dashboard/organization/[orgId]/plans/page.tsx new file mode 100644 index 000000000..574df832e --- /dev/null +++ b/app/dashboard/organization/[orgId]/plans/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { use, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2 } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +interface OrgPlan { + id: string; + planType: "CONSULTATION" | "SUBSCRIPTION" | "WEBINAR" | "CLASS" | "TRIAL"; + title: string; + description: string | null; + price: number; + priceCurrency: string; + isActive: boolean; +} + +const PLAN_TYPES = [ + { value: "CONSULTATION", label: "Consultation" }, + { value: "SUBSCRIPTION", label: "Subscription" }, + { value: "WEBINAR", label: "Webinar" }, + { value: "CLASS", label: "Class" }, +]; + +async function fetchPlans(orgId: string): Promise<{ plans: OrgPlan[] }> { + const res = await fetch(`/api/organizations/${orgId}/plans`); + if (!res.ok) throw new Error("Failed to load plans"); + return res.json(); +} + +interface CreatePlanPayload { + planType: string; + title: string; + description: string; + price: number; +} + +async function createPlan(orgId: string, payload: CreatePlanPayload) { + const res = await fetch(`/api/organizations/${orgId}/plans`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to create plan"); + return body; +} + +async function deletePlan(orgId: string, planId: string) { + const res = await fetch(`/api/organizations/${orgId}/plans/${planId}`, { + method: "DELETE", + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to delete plan"); + } +} + +export default function OrgPlansPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["org-plans", orgId], + queryFn: () => fetchPlans(orgId), + }); + + const [showCreate, setShowCreate] = useState(false); + const [planType, setPlanType] = useState("CONSULTATION"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priceMajor, setPriceMajor] = useState("0"); + const [error, setError] = useState(null); + + const createMutation = useMutation({ + mutationFn: () => + createPlan(orgId, { + planType, + title: title.trim(), + description: description.trim(), + price: Math.round(parseFloat(priceMajor || "0") * 100), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-plans", orgId] }); + setShowCreate(false); + setTitle(""); + setDescription(""); + setPriceMajor("0"); + setError(null); + }, + onError: (err: Error) => setError(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: (planId: string) => deletePlan(orgId, planId), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["org-plans", orgId] }), + }); + + return ( + <> + setShowCreate(true)}> + New plan + + } + /> + + + + + {isLoading ? "Loading…" : `${data?.plans.length ?? 0} plans`} + + + Plans created here can be assigned to learners and bookings. + + + + {isLoading ? ( +

Loading…

+ ) : ( + + + + Title + Type + Price + Status + + + + + {data?.plans.map((p) => ( + + {p.title} + + {p.planType} + + + {formatCurrencyAmount(p.price, p.priceCurrency)} + + + + {p.isActive ? "Active" : "Archived"} + + + + + + + ))} + {data && data.plans.length === 0 && ( + + + No plans yet. + + + )} + +
+ )} +
+
+
+ + + + + New plan + +
+
+ + +
+
+ + setTitle(e.target.value)} + placeholder="System design 1:1" + /> +
+
+ + setDescription(e.target.value)} + placeholder="Optional" + /> +
+
+ + setPriceMajor(e.target.value)} + /> +
+ {error &&

{error}

} +
+ + + + +
+
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/settings/page.tsx b/app/dashboard/organization/[orgId]/settings/page.tsx new file mode 100644 index 000000000..be6b0861f --- /dev/null +++ b/app/dashboard/organization/[orgId]/settings/page.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { Shield } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface OrgSettings { + organization: { + id: string; + name: string; + slug: string; + logo: string | null; + } | null; + profile: { + id: string; + kind: string; + status: string; + billingMode: string; + billingEmail: string; + description: string | null; + industry: string | null; + website: string | null; + paymentTermsDays: number; + seatsTotal: number | null; + }; +} + +async function fetchSettings(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/settings`); + if (!res.ok) throw new Error("Failed to load settings"); + return res.json(); +} + +interface PatchPayload { + name?: string; + billingEmail?: string; + description?: string | null; + industry?: string | null; + website?: string | null; + paymentTermsDays?: number; + seatsTotal?: number | null; +} + +async function patchSettings(orgId: string, payload: PatchPayload) { + const res = await fetch(`/api/organizations/${orgId}/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to update settings"); + return body; +} + +export default function OrgSettingsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["org-settings", orgId], + queryFn: () => fetchSettings(orgId), + }); + + const [name, setName] = useState(""); + const [billingEmail, setBillingEmail] = useState(""); + const [description, setDescription] = useState(""); + const [industry, setIndustry] = useState(""); + const [website, setWebsite] = useState(""); + const [paymentTermsDays, setPaymentTermsDays] = useState("30"); + const [seatsTotal, setSeatsTotal] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!data) return; + setName(data.organization?.name ?? ""); + setBillingEmail(data.profile.billingEmail); + setDescription(data.profile.description ?? ""); + setIndustry(data.profile.industry ?? ""); + setWebsite(data.profile.website ?? ""); + setPaymentTermsDays(String(data.profile.paymentTermsDays)); + setSeatsTotal( + data.profile.seatsTotal != null ? String(data.profile.seatsTotal) : "", + ); + }, [data]); + + const mutation = useMutation({ + mutationFn: () => + patchSettings(orgId, { + name: name.trim(), + billingEmail: billingEmail.trim(), + description: description.trim() || null, + industry: industry.trim() || null, + website: website.trim() || null, + paymentTermsDays: parseInt(paymentTermsDays, 10), + seatsTotal: seatsTotal ? parseInt(seatsTotal, 10) : null, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-settings", orgId] }); + queryClient.invalidateQueries({ queryKey: ["organization", orgId] }); + setSuccess(true); + setError(null); + setTimeout(() => setSuccess(false), 2500); + }, + onError: (err: Error) => { + setError(err.message); + setSuccess(false); + }, + }); + + if (isLoading) { + return ( + <> + + +

Loading…

+
+ + ); + } + + return ( + <> + + + + } + /> + + + + Profile + + These details are visible to your members and used on invoices. + + + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="space-y-4" + > +
+
+ + setName(e.target.value)} + /> +
+
+ + setBillingEmail(e.target.value)} + /> +
+
+ +
+ + setDescription(e.target.value)} + placeholder="Short description of the organization" + /> +
+ +
+
+ + setIndustry(e.target.value)} + placeholder="e.g. Education, Software" + /> +
+
+ + setWebsite(e.target.value)} + placeholder="https://example.com" + /> +
+
+ +
+
+ + setPaymentTermsDays(e.target.value)} + /> +
+
+ + setSeatsTotal(e.target.value)} + placeholder="Leave blank for unlimited" + /> +
+
+ + {error &&

{error}

} + {success && ( +

Settings saved.

+ )} + +
+ +
+
+
+
+ + + + Billing mode + + Currently {data?.profile.billingMode}. Billing + mode is locked after the first payment to prevent ambiguity in + the ledger. + + + +
+ + ); +} diff --git a/app/dashboard/organization/[orgId]/settings/sso/page.tsx b/app/dashboard/organization/[orgId]/settings/sso/page.tsx new file mode 100644 index 000000000..998f6d12b --- /dev/null +++ b/app/dashboard/organization/[orgId]/settings/sso/page.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2, ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface Provider { + id: string; + providerId: string; + issuer: string; + domain: string; +} + +interface SsoResponse { + settings: { + allowedEmailDomains: string[]; + enforceSSO: boolean; + defaultRoleForAutoJoin: string; + }; + providers: Provider[]; +} + +async function fetchSso(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/sso`); + if (!res.ok) throw new Error("Failed to load SSO settings"); + return res.json(); +} + +interface PatchPayload { + allowedEmailDomains?: string[]; + enforceSSO?: boolean; + defaultRoleForAutoJoin?: string; +} + +async function patchSso(orgId: string, payload: PatchPayload) { + const res = await fetch(`/api/organizations/${orgId}/sso`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to update SSO settings"); + return body; +} + +interface ProviderPayload { + providerId: string; + domain: string; + issuer: string; + samlConfig?: string; + oidcConfig?: string; +} + +async function createProvider(orgId: string, payload: ProviderPayload) { + const res = await fetch(`/api/organizations/${orgId}/sso/providers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || "Failed to register provider"); + return body; +} + +async function deleteProvider(orgId: string, providerId: string) { + const res = await fetch( + `/api/organizations/${orgId}/sso/providers/${providerId}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to delete provider"); + } +} + +const ROLE_OPTIONS = [ + { value: "ORG_LEARNER", label: "Learner" }, + { value: "ORG_MANAGER", label: "Manager" }, + { value: "ORG_ADMIN", label: "Admin" }, +]; + +export default function OrgSsoPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = use(params); + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ["org-sso", orgId], + queryFn: () => fetchSso(orgId), + }); + + const [domains, setDomains] = useState(""); + const [enforce, setEnforce] = useState(false); + const [defaultRole, setDefaultRole] = useState("ORG_LEARNER"); + + useEffect(() => { + if (!data) return; + setDomains(data.settings.allowedEmailDomains.join(", ")); + setEnforce(data.settings.enforceSSO); + setDefaultRole(data.settings.defaultRoleForAutoJoin); + }, [data]); + + const settingsMutation = useMutation({ + mutationFn: () => + patchSso(orgId, { + allowedEmailDomains: domains + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + enforceSSO: enforce, + defaultRoleForAutoJoin: defaultRole, + }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["org-sso", orgId] }), + }); + + const [showAdd, setShowAdd] = useState(false); + const [providerId, setProviderId] = useState(""); + const [domain, setDomain] = useState(""); + const [issuer, setIssuer] = useState(""); + const [samlConfig, setSamlConfig] = useState(""); + const [providerError, setProviderError] = useState(null); + + const createProviderMutation = useMutation({ + mutationFn: () => + createProvider(orgId, { + providerId: providerId.trim(), + domain: domain.trim(), + issuer: issuer.trim(), + samlConfig: samlConfig.trim() || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-sso", orgId] }); + setShowAdd(false); + setProviderId(""); + setDomain(""); + setIssuer(""); + setSamlConfig(""); + setProviderError(null); + }, + onError: (err: Error) => setProviderError(err.message), + }); + + const deleteProviderMutation = useMutation({ + mutationFn: (id: string) => deleteProvider(orgId, id), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["org-sso", orgId] }), + }); + + if (isLoading) { + return ( + <> + + +

Loading…

+
+ + ); + } + + return ( + <> + + + + } + /> + + + + Domain policy + + Users signing up with these email domains can be auto-joined to + this organization. + + + +
{ + e.preventDefault(); + settingsMutation.mutate(); + }} + > +
+ + setDomains(e.target.value)} + placeholder="acme.com, acme.edu" + /> +

+ Comma-separated list of domains. +

+
+ +
+
+

Enforce SSO

+

+ Reject password and personal OAuth sign-ins for these + domains. +

+
+ +
+ +
+ + +
+ +
+ +
+
+
+
+ + + +
+ SSO providers + + SAML and OIDC providers registered for this organization. + +
+ +
+ + + + + Provider ID + Domain + Issuer + + + + + {data?.providers.map((p) => ( + + + {p.providerId} + + {p.domain} + + {p.issuer} + + + + + + ))} + {data?.providers && data.providers.length === 0 && ( + + + No providers configured yet. + + + )} + +
+
+
+
+ + + + + Add SSO provider + +
+
+ + setProviderId(e.target.value)} + placeholder="acme-okta" + /> +
+
+ + setDomain(e.target.value)} + placeholder="acme.com" + /> +
+
+ + setIssuer(e.target.value)} + placeholder="https://idp.acme.com" + /> +
+
+ +