diff --git a/__tests__/stream/recording-capability.test.ts b/__tests__/stream/recording-capability.test.ts new file mode 100644 index 000000000..778ffbf7e --- /dev/null +++ b/__tests__/stream/recording-capability.test.ts @@ -0,0 +1,112 @@ +/** + * @jest-environment node + */ + +/** + * #1134 P1-6 — recording a 1:1 was not disabled, it was IMPOSSIBLE. + * + * `isAppointmentOwner` and `isRecordingEnabledForAppointment` each hand-rolled + * an if/else over `webinar` and `class` only. For a consultation or a + * subscription both fell through to `false`, so the consultant who owned the + * session failed the ownership check and `POST /api/stream/recordings/start` + * answered 403 — to the owner. `recording-info` reported `recordingEnabled: + * false` no matter what, above a comment noting the 1:1 plans had no such + * field. + * + * Both predicates now read one resolver, so they cannot disagree about which + * plan they are looking at — which is the shape of the original bug. + */ + +import { + isAppointmentOwner, + isRecordingEnabledForAppointment, + resolveAppointmentPlan, + type AppointmentWithOwnership, + type OwnedPlan, +} from "@/lib/stream/recording-utils"; + +const OWNER = "consultant-profile-1"; +const OTHER = "consultant-profile-2"; + +const withPlan = (kind: string, plan: OwnedPlan): AppointmentWithOwnership => { + switch (kind) { + case "webinar": + return { webinar: { webinarPlan: plan } }; + case "class": + return { class: { classPlan: plan } }; + case "consultation": + return { consultation: { consultationPlan: plan } }; + default: + return { subscription: { subscriptionPlan: plan } }; + } +}; + +const KINDS = ["webinar", "class", "consultation", "subscription"] as const; + +describe("recording capability covers every appointment kind", () => { + it.each(KINDS)("%s: the owning consultant is the owner", (kind) => { + const appointment = withPlan(kind, { + consultantProfileId: OWNER, + recordingEnabled: true, + }); + expect(isAppointmentOwner(appointment, OWNER)).toBe(true); + expect(isAppointmentOwner(appointment, OTHER)).toBe(false); + }); + + it.each(KINDS)("%s: recordingEnabled is read from the plan", (kind) => { + expect( + isRecordingEnabledForAppointment( + withPlan(kind, { consultantProfileId: OWNER, recordingEnabled: true }), + ), + ).toBe(true); + expect( + isRecordingEnabledForAppointment( + withPlan(kind, { consultantProfileId: OWNER, recordingEnabled: false }), + ), + ).toBe(false); + }); + + it("defaults closed when the plan omits the flag", () => { + // A 1:1 is the most sensitive session type on the platform. An absent flag + // must never read as consent to record. + expect( + isRecordingEnabledForAppointment( + withPlan("consultation", { consultantProfileId: OWNER }), + ), + ).toBe(false); + }); + + it("never treats a missing consultantProfileId as ownership", () => { + // Guards the null-vs-null trap: an appointment whose plan has no consultant + // must not match a caller who also has none. + const appointment = withPlan("consultation", { + consultantProfileId: null, + recordingEnabled: true, + }); + expect(isAppointmentOwner(appointment, null)).toBe(false); + expect(isAppointmentOwner(appointment, undefined)).toBe(false); + }); + + it("resolves nothing for an empty or absent appointment", () => { + expect(resolveAppointmentPlan(null)).toBeNull(); + expect(resolveAppointmentPlan(undefined)).toBeNull(); + expect(resolveAppointmentPlan({})).toBeNull(); + expect(isAppointmentOwner(null, OWNER)).toBe(false); + expect(isRecordingEnabledForAppointment(null)).toBe(false); + }); + + it("the two predicates always read the same plan", () => { + // The original defect was divergence: ownership looked at one set of kinds + // and the recording flag at another. Assert they agree on every kind. + for (const kind of KINDS) { + const appointment = withPlan(kind, { + consultantProfileId: OWNER, + recordingEnabled: true, + }); + const plan = resolveAppointmentPlan(appointment); + expect(plan?.consultantProfileId).toBe(OWNER); + expect(isAppointmentOwner(appointment, OWNER)).toBe(true); + expect(isRecordingEnabledForAppointment(appointment)).toBe(true); + } + }); +}); diff --git a/__tests__/stream/webhook-not-rate-limited.test.ts b/__tests__/stream/webhook-not-rate-limited.test.ts new file mode 100644 index 000000000..cb3abb9af --- /dev/null +++ b/__tests__/stream/webhook-not-rate-limited.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment node + */ + +/** + * #1134 P1-11 — the Stream webhook endpoint must not be edge rate-limited. + * + * The `stream: api` rule matched `/api/stream/` by prefix, which swept in + * `/api/stream/webhooks`. That is a worse failure than it sounds: + * + * - Stream POSTs every delivery from its own infrastructure, so all of them + * collapse onto a single rate-limit key rather than spreading across users. + * - Bursts are the NORMAL shape. A 200-attendee webinar emits 200 + * `call.session_participant_joined` events at once. + * - A 429 is not a deferral here. Stream retries inside a fifteen-second + * total budget and then drops the event permanently. + * + * So throttling this path would have silently reintroduced the exact loss that + * #1137's persist-before-ack work exists to prevent — and done it in the + * middleware, before the route ever ran, where none of that machinery applies. + * + * Excluding it is safe because the endpoint is not open: it verifies an HMAC + * signature against the API secret and 401s anything unsigned before doing any + * work. The signature is the gate; the limiter never was. + * + * This asserts the ROUTE TABLE rather than booting the middleware, because the + * matcher predicates are the whole of the behaviour under test and the + * middleware itself pulls in Next's edge runtime, Redis and the maintenance + * store. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +const middleware = readFileSync( + join(process.cwd(), "middleware.ts"), + "utf8", +); + +/** The `stream: api` rule's match predicate, lifted from the source. */ +function streamApiMatches(pathname: string): boolean { + return ( + pathname.startsWith("/api/stream/") && + !pathname.startsWith("/api/stream/webhooks") + ); +} + +describe("the stream: api rate-limit rule", () => { + it("does NOT match the webhook endpoint", () => { + expect(streamApiMatches("/api/stream/webhooks")).toBe(false); + }); + + it("still matches the ordinary authenticated Stream routes", () => { + for (const p of [ + "/api/stream/channels/search-appointments", + "/api/stream/recordings/start", + "/api/stream/search-consultees", + "/api/stream/debug", + ]) { + expect(streamApiMatches(p)).toBe(true); + } + }); + + it("is wired that way in middleware.ts, not just in this test", () => { + // The predicate above is a copy. This is the part that fails if someone + // simplifies the rule back to a bare prefix match. + expect(middleware).toContain('!p.startsWith("/api/stream/webhooks")'); + }); + + it("does not claim the join rule is keyed per user", () => { + // `applyEdgeRateLimits` falls back to the client IP when a rule supplies no + // `key`, and the join rule supplies none. The comment used to assert + // per-user keying, which the code cannot do — this middleware is + // cookie-presence only, with no DB hit and no JWT parsing. + expect(middleware).not.toContain("keyed per user by the shared"); + expect(middleware).toContain("Keyed by IP, NOT by user"); + }); +}); diff --git a/app/api/stream/debug/route.ts b/app/api/stream/debug/route.ts index 3a3c89b5d..4a58020c0 100644 --- a/app/api/stream/debug/route.ts +++ b/app/api/stream/debug/route.ts @@ -10,6 +10,8 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import { getStreamChatClient, isStreamConfigured } from "@/lib/stream-client"; +import { getSession } from "@/lib/auth-server"; +import { isPrivileged } from "@/lib/auth-helpers"; import { streamLogger } from "@/lib/stream-logger"; import prisma from "@/lib/prisma"; @@ -21,6 +23,23 @@ export async function GET(req: NextRequest) { // Security checks const isDev = process.env.NODE_ENV === "development"; + // #1134 P1-12 — this route had NO session check at all. Its only production + // gate was a shared secret in the query string — which lands in access logs, + // browser history and any Referer header — and it dumps an arbitrary user's + // full Stream channel list. A session is now required everywhere, and staff + // or admin on top of that, so the secret is defence in depth rather than the + // whole defence. + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isPrivileged(session.user.role)) { + streamLogger.warn("Non-privileged Stream debug attempt", { + userId: session.user.id, + }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!isDev && !ALLOW_IN_PRODUCTION) { return NextResponse.json( { error: "Debug endpoint not available in production" }, @@ -33,8 +52,12 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const secret = url.searchParams.get("secret"); - if (!secret || secret !== DEBUG_SECRET) { - streamLogger.warn("Unauthorized debug attempt"); + // A missing STREAM_DEBUG_SECRET must fail closed. `secret !== undefined` + // would have compared two undefineds and passed. + if (!DEBUG_SECRET || !secret || secret !== DEBUG_SECRET) { + streamLogger.warn("Unauthorized debug attempt", { + userId: session.user.id, + }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } } diff --git a/app/api/stream/meetings/[streamCallId]/recording-info/route.ts b/app/api/stream/meetings/[streamCallId]/recording-info/route.ts index 494c1d205..3e1b3003d 100644 --- a/app/api/stream/meetings/[streamCallId]/recording-info/route.ts +++ b/app/api/stream/meetings/[streamCallId]/recording-info/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { isPaymentEntitled } from "@/lib/payments/utils/refund-balance"; +import { isRecordingEnabledForAppointment } from "@/lib/stream/recording-utils"; import { isPrivileged } from "@/lib/auth-helpers"; import { getSession } from "@/lib/auth-server"; @@ -60,7 +61,13 @@ export async function GET(req: NextRequest, { params }: RouteParams) { consultation: { include: { consultationPlan: { - select: { consultantProfileId: true }, + // #1134 P1-6 — recordingEnabled is new on the 1:1 plans; + // without selecting it the resolver reports recording as + // unavailable for every consultation. + select: { + consultantProfileId: true, + recordingEnabled: true, + }, }, requestedBy: { select: { userId: true }, @@ -70,7 +77,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) { subscription: { include: { subscriptionPlan: { - select: { consultantProfileId: true }, + select: { + consultantProfileId: true, + recordingEnabled: true, + }, }, requestedBy: { select: { userId: true }, @@ -173,15 +183,13 @@ export async function GET(req: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } - // Determine if recording is enabled based on appointment type - let recordingEnabled = false; - - if (appointment?.webinar?.webinarPlan) { - recordingEnabled = appointment.webinar.webinarPlan.recordingEnabled; - } else if (appointment?.class?.classPlan) { - recordingEnabled = appointment.class.classPlan.recordingEnabled; - } - // Consultations and subscriptions don't have recordingEnabled on their plans + // #1134 P1-6 — one resolver for all four plan kinds. This used to be a + // hand-rolled if/else over webinar and class with a comment explaining that + // consultations and subscriptions "don't have recordingEnabled on their + // plans" — true at the time, and the reason 1:1 recording was impossible + // rather than merely off. They have it now, and the shared helper means + // this can no longer disagree with the ownership check beside it. + const recordingEnabled = isRecordingEnabledForAppointment(appointment); return NextResponse.json({ meetingSessionId: meetingSession.id, diff --git a/app/api/stream/recordings/start/route.ts b/app/api/stream/recordings/start/route.ts index 666f1a1b9..247f23795 100644 --- a/app/api/stream/recordings/start/route.ts +++ b/app/api/stream/recordings/start/route.ts @@ -67,6 +67,30 @@ export async function POST(req: NextRequest) { }, }, }, + // #1134 P1-6 — without these two the resolver sees no plan for a + // 1:1, so the actual owner fails isAppointmentOwner and start + // returns 403. Recording a consultation was not disabled, it was + // impossible. + consultation: { + include: { + consultationPlan: { + select: { + consultantProfileId: true, + recordingEnabled: true, + }, + }, + }, + }, + subscription: { + include: { + subscriptionPlan: { + select: { + consultantProfileId: true, + recordingEnabled: true, + }, + }, + }, + }, }, }, }, diff --git a/app/api/stream/recordings/stop/route.ts b/app/api/stream/recordings/stop/route.ts index a59c16fe0..5a9882e69 100644 --- a/app/api/stream/recordings/stop/route.ts +++ b/app/api/stream/recordings/stop/route.ts @@ -67,6 +67,26 @@ export async function POST(req: NextRequest) { }, }, }, + // #1134 P1-6 — mirror the start route: without these the owner + // of a 1:1 cannot stop a recording they were able to start. + consultation: { + include: { + consultationPlan: { + select: { + consultantProfileId: true, + }, + }, + }, + }, + subscription: { + include: { + subscriptionPlan: { + select: { + consultantProfileId: true, + }, + }, + }, + }, }, }, }, diff --git a/lib/payments/webhooks/handlers.ts b/lib/payments/webhooks/handlers.ts index 1ef7c80b7..19abaf61a 100644 --- a/lib/payments/webhooks/handlers.ts +++ b/lib/payments/webhooks/handlers.ts @@ -943,7 +943,13 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! // cohered. One thread per relationship-context is the whole model now. if ( (eventType === "CONSULTATION" && consultation) || - (eventType === "SUBSCRIPTION" && subscription) + (eventType === "SUBSCRIPTION" && subscription) || + // #1134 P1-16 — TRIAL had no branch here at all, so a trial buyer got + // video and no way to message the consultant before or after it. A + // trial is the platform's first impression; it is the LAST session + // type that should be mute. Same DM as any other 1:1, so it merges + // with their thread if they go on to book. + eventType === "TRIAL" ) { await createDirectMessageChannel( consultantUserId, diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 62a8506c9..ff2e37eb9 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -87,6 +87,23 @@ export const spamLimiter = makeLimiter(5, "1 h", "rl:spam"); */ export const cspReportLimiter = makeLimiter(120, "1 m", "rl:csp-report"); +/** + * #1134 P1-11 — Stream had NO rate limiting on any route or server action. + * + * Two shapes, two budgets: + * + * `streamJoinLimiter` guards the meeting join gate. It is the enumeration + * surface: call ids are deterministic (`slot-`), so an attacker + * who has one slot id can walk neighbours. Generous enough that a flaky network + * retrying a join never trips it, tight enough that scanning is useless. + * + * `streamApiLimiter` guards the search / channel-create / block routes, which + * are ordinary authenticated reads and writes but were completely unbounded — + * every one of them costs a Stream API call we are billed for. + */ +export const streamJoinLimiter = makeLimiter(20, "1 m", "rl:stream-join"); +export const streamApiLimiter = makeLimiter(60, "1 m", "rl:stream-api"); + /** 3 per 24 hours — POST /api/trials (prevents flooding consultant inboxes) */ export const trialRequestLimiter = makeLimiter(3, "24 h", "rl:trial-request"); diff --git a/lib/stream/recording-utils.ts b/lib/stream/recording-utils.ts index b76de0d87..90bd230a3 100644 --- a/lib/stream/recording-utils.ts +++ b/lib/stream/recording-utils.ts @@ -9,19 +9,39 @@ import prisma from "@/lib/prisma"; * Type for appointment with ownership relations * Used for checking if a consultant owns a recording/session */ +export interface OwnedPlan { + consultantProfileId: string | null; + recordingEnabled?: boolean; +} + export interface AppointmentWithOwnership { - webinar?: { - webinarPlan?: { - consultantProfileId: string | null; - recordingEnabled?: boolean; - } | null; - } | null; - class?: { - classPlan?: { - consultantProfileId: string | null; - recordingEnabled?: boolean; - } | null; - } | null; + webinar?: { webinarPlan?: OwnedPlan | null } | null; + class?: { classPlan?: OwnedPlan | null } | null; + // #1134 P1-6 — 1:1 was simply absent here, which is why recording a + // consultation or a subscription was impossible rather than merely disabled: + // isAppointmentOwner returned false for the actual owner, so start-recording + // 403'd, and isRecordingEnabledForAppointment reported false regardless of + // what the plan said. + consultation?: { consultationPlan?: OwnedPlan | null } | null; + subscription?: { subscriptionPlan?: OwnedPlan | null } | null; +} + +/** + * The plan behind an appointment, whichever of the four kinds it is. + * One resolver so ownership and the recording flag can never disagree about + * which plan they are reading — the bug above was exactly that divergence. + */ +export function resolveAppointmentPlan( + appointment: AppointmentWithOwnership | null | undefined, +): OwnedPlan | null { + if (!appointment) return null; + return ( + appointment.webinar?.webinarPlan ?? + appointment.class?.classPlan ?? + appointment.consultation?.consultationPlan ?? + appointment.subscription?.subscriptionPlan ?? + null + ); } /** @@ -35,26 +55,9 @@ export function isAppointmentOwner( appointment: AppointmentWithOwnership | null | undefined, consultantProfileId: string | null | undefined, ): boolean { - if (!appointment || !consultantProfileId) { - return false; - } - - // Check webinar ownership - if (appointment.webinar?.webinarPlan) { - return ( - appointment.webinar.webinarPlan.consultantProfileId === - consultantProfileId - ); - } - - // Check class ownership - if (appointment.class?.classPlan) { - return ( - appointment.class.classPlan.consultantProfileId === consultantProfileId - ); - } - - return false; + if (!consultantProfileId) return false; + const plan = resolveAppointmentPlan(appointment); + return !!plan && plan.consultantProfileId === consultantProfileId; } /** @@ -66,21 +69,7 @@ export function isAppointmentOwner( export function isRecordingEnabledForAppointment( appointment: AppointmentWithOwnership | null | undefined, ): boolean { - if (!appointment) { - return false; - } - - // Check webinar plan - if (appointment.webinar?.webinarPlan) { - return appointment.webinar.webinarPlan.recordingEnabled === true; - } - - // Check class plan - if (appointment.class?.classPlan) { - return appointment.class.classPlan.recordingEnabled === true; - } - - return false; + return resolveAppointmentPlan(appointment)?.recordingEnabled === true; } /** diff --git a/middleware.ts b/middleware.ts index 61f635ea7..0ba4e8be8 100644 --- a/middleware.ts +++ b/middleware.ts @@ -22,6 +22,8 @@ import { applyRateLimit, getClientIp, isBypassableIp, + streamJoinLimiter, + streamApiLimiter, } from "@/lib/rate-limit"; import { Ratelimit } from "@upstash/ratelimit"; @@ -248,6 +250,48 @@ const RATE_LIMIT_RULES: RateRule[] = [ limiter: authLimiter, skipLocalhost: true, }, + { + // #1134 P1-11 — the meeting join gate. Call ids are deterministic + // (`slot-`), so this is the enumeration surface: without a + // limit, someone holding one slot id can walk neighbours and probe which + // meetings they can reach. + // + // Keyed by IP, NOT by user — an earlier version of this comment claimed the + // opposite. `applyEdgeRateLimits` falls back to the client IP whenever a + // rule supplies no `key`, and this rule supplies none. Per-user keying is + // not available here by design: this middleware is cookie-presence only, + // with no DB hit and no JWT parsing, so it cannot resolve a user id cheaply. + // + // IP-keying is the right shape for enumeration anyway, since a walker works + // from one address. The cost is that users behind a shared NAT share a + // bucket, which is why the limit is generous rather than tight. + label: "stream: meeting join", + match: (p, m) => m === "POST" && /^\/api\/meetings\/[^/]+\/join$/.test(p), + limiter: streamJoinLimiter, + skipLocalhost: true, + }, + { + // Ordinary authenticated Stream reads/writes — search, channel create, + // block. Unbounded before, and each one costs a billable Stream API call. + // + // EXCLUDES the webhook endpoint. Stream POSTs every delivery from its own + // infrastructure, so they all collapse onto one rate-limit key, and a burst + // is the normal shape — a 200-attendee webinar emits 200 + // `call.session_participant_joined` events at once. A 429 there is not a + // deferral: Stream retries inside a fifteen-second total budget and then + // DROPS the event permanently, which is precisely the loss #1137's + // ack-first/persist-first work exists to prevent. Throttling it would have + // undone that from the middleware, before the route ever ran. + // + // Safe to exclude because the endpoint is not open: it verifies an HMAC + // signature against the API secret and 401s anything unsigned before doing + // any work. The signature is the gate, not the limiter. + label: "stream: api", + match: (p) => + p.startsWith("/api/stream/") && !p.startsWith("/api/stream/webhooks"), + limiter: streamApiLimiter, + skipLocalhost: true, + }, { label: "public: consultant search / explore", match: (p) => p.startsWith("/api/user/consultants"), diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43ef3695a..6354a63e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2838,27 +2838,36 @@ model SlotOfAvailabilityCustom { // 1-1 Consultation model ConsultationPlan { - id String @id @default(cuid()) - title String + id String @id @default(cuid()) + title String // One-line pitch rendered under the title. Distinct from `description`, // which is long-form and gets truncated in cards. - subtitle String? @db.VarChar(160) - description String? @db.Text - durationInHours Float @default(1) - price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) - priceCurrency Currency @default(INR) - language String @default("English") - level PlanLevel @default(BEGINNER) - prerequisites String? @default("None") - materialProvided String? @default("None") - learningOutcomes String[] @default([]) + subtitle String? @db.VarChar(160) + description String? @db.Text + durationInHours Float @default(1) + price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency Currency @default(INR) + + // #1134 P1-6 — 1:1 recording was structurally impossible: isAppointmentOwner + // and isRecordingEnabledForAppointment only understood webinar and class, so + // a consultation or subscription always 403'd on start and reported + // recordingEnabled:false. Mirrors WebinarPlan/ClassPlan exactly, and defaults + // OFF — a 1:1 is the most sensitive session type on the platform, so + // recording it stays an explicit per-plan opt-in by the consultant. + recordingEnabled Boolean @default(false) + recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) + language String @default("English") + level PlanLevel @default(BEGINNER) + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) // Buyer-facing positioning: who the offering is for, and what the price buys. - targetAudience String[] @default([]) - whatsIncluded String[] @default([]) - imageUrl String? + targetAudience String[] @default([]) + whatsIncluded String[] @default([]) + imageUrl String? // Reserved for SEO-friendly plan URLs. Nullable until routing lands (#696). - slug String? @unique - topics Topic[] @relation("TopicToConsultationPlan") + slug String? @unique + topics Topic[] @relation("TopicToConsultationPlan") consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String @@ -2929,32 +2938,41 @@ model Consultation { } model SubscriptionPlan { - id String @id @default(cuid()) - title String + id String @id @default(cuid()) + title String // See ConsultationPlan.subtitle. - subtitle String? @db.VarChar(160) - description String? @db.Text - durationInMonths Int @default(1) - price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) - priceCurrency Currency @default(INR) + subtitle String? @db.VarChar(160) + description String? @db.Text + durationInMonths Int @default(1) + price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency Currency @default(INR) + + // #1134 P1-6 — 1:1 recording was structurally impossible: isAppointmentOwner + // and isRecordingEnabledForAppointment only understood webinar and class, so + // a consultation or subscription always 403'd on start and reported + // recordingEnabled:false. Mirrors WebinarPlan/ClassPlan exactly, and defaults + // OFF — a 1:1 is the most sensitive session type on the platform, so + // recording it stays an explicit per-plan opt-in by the consultant. + recordingEnabled Boolean @default(false) + recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) // #1011 — was `callsPerWeek`. Renamed to match ClassPlan so both recurring // plan types name the same quantity the same way before the schema freeze. - sessionsPerWeek Int @default(1) - sessionDurationInHours Float @default(1.0) // Duration of each session in hours - totalSessions Int @default(4) // sessionsPerWeek × durationInMonths × 4 - totalHours Float @default(4.0) // totalSessions × sessionDurationInHours - emailSupport PlanEmailSupport @default(GENERAL) - language String @default("English") - level PlanLevel @default(BEGINNER) - prerequisites String? @default("None") - materialProvided String? @default("None") - learningOutcomes String[] @default([]) + sessionsPerWeek Int @default(1) + sessionDurationInHours Float @default(1.0) // Duration of each session in hours + totalSessions Int @default(4) // sessionsPerWeek × durationInMonths × 4 + totalHours Float @default(4.0) // totalSessions × sessionDurationInHours + emailSupport PlanEmailSupport @default(GENERAL) + language String @default("English") + level PlanLevel @default(BEGINNER) + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) // See ConsultationPlan.targetAudience. - targetAudience String[] @default([]) - whatsIncluded String[] @default([]) + targetAudience String[] @default([]) + whatsIncluded String[] @default([]) imageUrl String? - slug String? @unique - topics Topic[] @relation("TopicToSubscriptionPlan") + slug String? @unique + topics Topic[] @relation("TopicToSubscriptionPlan") // Trial session offer. Free by default ONLY until paid-trial checkout is // wired (booking rejects paid trials today); the wiring PR flips the @@ -3622,9 +3640,9 @@ model RescheduleRequest { /// withdraw route and slotsAllowReschedule all read "at most one" as given. openForAppointmentId String? @unique - resolvedAt DateTime? @db.Timestamptz - resolvedBy User? @relation("RescheduleResolver", fields: [resolvedById], references: [id], onDelete: SetNull) - resolvedById String? + resolvedAt DateTime? @db.Timestamptz + resolvedBy User? @relation("RescheduleResolver", fields: [resolvedById], references: [id], onDelete: SetNull) + resolvedById String? /// The answering party's reply. `reason` runs consultee -> consultant; this /// is the return leg, and it is the difference between "your sessions moved" /// and "moved to Thursdays, I have blocked Tuesdays from September". @@ -4547,15 +4565,15 @@ model PayoutAccount { ////////////////////////////////////////// TAX & COMPLIANCE ////////////////////////////////////////// model ConsultantTaxInfo { - id String @id @default(cuid()) - consultantProfileId String @unique + 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) + 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? /// #1132 — limb 1 of the 194-O ₹5L exemption: only INDIVIDUAL/HUF get a