diff --git a/.github/workflows/send-appointment-reminders.yml b/.github/workflows/send-appointment-reminders.yml new file mode 100644 index 000000000..47ce36a95 --- /dev/null +++ b/.github/workflows/send-appointment-reminders.yml @@ -0,0 +1,49 @@ +name: Send Appointment Reminders + +on: + schedule: + # Hourly — the 1-hour reminder window (45–75 min before start) assumes + # at-least-hourly firing; Redis SET-NX in the script dedupes overlaps. + - cron: "12 * * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + send-appointment-reminders: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + # #476 cron locks load lib/redis at import — every job entry needs these + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # Reminder notifications fan out through Novu; links built via getAppUrl + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Send appointment reminders + run: npx tsx jobs/appointments/send-appointment-reminders.ts + + - name: Notify on failure + if: failure() + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "send-appointment-reminders" diff --git a/__tests__/booking-algorithm/rescheduleCancel.test.ts b/__tests__/booking-algorithm/rescheduleCancel.test.ts index 447de4088..654a04751 100644 --- a/__tests__/booking-algorithm/rescheduleCancel.test.ts +++ b/__tests__/booking-algorithm/rescheduleCancel.test.ts @@ -1170,6 +1170,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date("2025-01-01T10:30:00.000Z"), isTentative: true, createdAt: new Date("2024-12-01T00:00:00.000Z"), + updatedAt: new Date("2024-12-01T00:00:00.000Z"), appointment: { payment: [], consultation: { @@ -1205,6 +1206,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date(), isTentative: true, createdAt: new Date("2024-12-01"), + updatedAt: new Date("2024-12-01"), appointment: { payment: [], consultation: null, subscription: null }, }, { @@ -1214,6 +1216,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date(), isTentative: true, createdAt: new Date("2024-12-01"), + updatedAt: new Date("2024-12-01"), appointment: { payment: [], consultation: null, subscription: null }, }, { @@ -1223,6 +1226,7 @@ describe("cleanupTentativeSlots", () => { endsAt: new Date(), isTentative: true, createdAt: new Date("2024-12-01"), + updatedAt: new Date("2024-12-01"), appointment: { payment: [], consultation: null, subscription: null }, }, ]; diff --git a/__tests__/booking-algorithm/slotAllocationService.test.ts b/__tests__/booking-algorithm/slotAllocationService.test.ts index 346294591..f3a345fb0 100644 --- a/__tests__/booking-algorithm/slotAllocationService.test.ts +++ b/__tests__/booking-algorithm/slotAllocationService.test.ts @@ -91,8 +91,17 @@ function makeMockTx() { update: jest.fn(), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, - webinar: { findUnique: jest.fn(), update: jest.fn() }, - class: { findUnique: jest.fn(), update: jest.fn() }, + webinar: { + findUnique: jest.fn(), + update: jest.fn(), + // Guarded transitions (transitionWebinarEvent) use WHERE-guarded updateMany + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + class: { + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, // #440 — createAppointments denormalizes the consultant onto each slot. consultantProfile: { findFirst: jest.fn().mockResolvedValue({ id: "consultant-profile-1" }), @@ -1128,9 +1137,14 @@ describe("Auto allocation", () => { mode: "auto", }); - expect(mockTx.webinar.update).toHaveBeenCalledWith( + // Guarded transition: WHERE-guarded updateMany (EVENT_ALLOWED_FROM), + // so a CANCELLED/COMPLETED webinar can no longer be resurrected. + expect(mockTx.webinar.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "webinar-1" }, + where: expect.objectContaining({ + id: "webinar-1", + status: expect.objectContaining({ in: expect.any(Array) }), + }), data: expect.objectContaining({ status: "SCHEDULED" }), }), ); @@ -1382,7 +1396,7 @@ describe("updateEventStatus", () => { slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"], }); - const updateData = mockTx.webinar.update.mock.calls[0][0].data; + const updateData = mockTx.webinar.updateMany.mock.calls[0][0].data; expect(updateData.status).toBe("SCHEDULED"); // Webinar should NOT have scheduling period fields expect(updateData.schedulingPeriodStartsAt).toBeUndefined(); @@ -1406,10 +1420,11 @@ describe("updateEventStatus", () => { slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"], }); - const updateData = mockTx.class.update.mock.calls[0][0].data; - expect(updateData.status).toBe("SCHEDULED"); - expect(updateData.schedulingPeriodStartsAt).toBeDefined(); - expect(updateData.schedulingPeriodEndsAt).toBeDefined(); + // Guarded transition: status rides transitionClassEvent's updateMany + const updateCall = mockTx.class.updateMany.mock.calls[0][0]; + expect(updateCall.data.status).toBe("SCHEDULED"); + expect(updateCall.data.schedulingPeriodStartsAt).toBeDefined(); + expect(updateCall.data.schedulingPeriodEndsAt).toBeDefined(); }); }); @@ -2183,14 +2198,10 @@ describe("Edge cases", () => { // Correct model was queried (read runs on the base client now) expect((prisma as any)[eventType].findUnique).toHaveBeenCalled(); - // Correct model was updated — consultation/subscription go through - // the #836 CAS transition helpers (updateMany); webinar/class still - // use a plain update in updateEventStatus. - const mutator = - eventType === "consultation" || eventType === "subscription" - ? freshTx[eventType].updateMany - : freshTx[eventType].update; - expect(mutator).toHaveBeenCalled(); + // Correct model was updated — ALL four types now go through + // WHERE-guarded CAS transitions (updateMany): #836 for + // consultation/subscription, EVENT_ALLOWED_FROM for webinar/class. + expect(freshTx[eventType].updateMany).toHaveBeenCalled(); } }); }); diff --git a/__tests__/booking/cleanup-tentative-guard.test.ts b/__tests__/booking/cleanup-tentative-guard.test.ts index a744999b9..09cb6e9fa 100644 --- a/__tests__/booking/cleanup-tentative-guard.test.ts +++ b/__tests__/booking/cleanup-tentative-guard.test.ts @@ -43,6 +43,7 @@ describe("#829 — cleanup delete re-states the tentative + unpaid guards", () = id: "slot-1", appointmentId: "appt-1", createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date("2026-05-01T00:00:00Z"), startsAt: new Date("2026-05-02T10:00:00Z"), endsAt: new Date("2026-05-02T11:00:00Z"), appointment: { payment: [], consultation: null, subscription: null }, diff --git a/__tests__/payments/refund-operation.test.ts b/__tests__/payments/refund-operation.test.ts index b8d828fcc..9f073e52d 100644 --- a/__tests__/payments/refund-operation.test.ts +++ b/__tests__/payments/refund-operation.test.ts @@ -828,6 +828,54 @@ describe("refundPayment — M1 gateway wiring", () => { expect(state.refunds[0]?.cascadedAt).toBeTruthy(); }); + it("release #1014 review — gateway metadata MERGES into the row, preserving Phase-1 audit keys", async () => { + seedSinglePartyWalletPayment({}); + // Razorpay always returns notes (reason at minimum), so this is the + // every-refund path, not an edge case. + mockCreateGatewayRefund.mockResolvedValueOnce({ + refundId: "rfnd_gw_meta", + amount: 10000, + currency: "INR", + status: "SUCCEEDED", + metadata: { reason: "customer request", gw_key: "gw_val" }, + }); + + await refundPayment({ + paymentId: "pay-1", + reason: "customer request", + initiatedByUserId: "admin-1", + }); + + const meta = state.refunds[0]?.metadata as Record; + // Phase-1 keys survive the gateway-id binding... + expect(meta.initiatedByUserId).toBe("admin-1"); + expect(meta.source).toBe("app"); + // ...and the gateway keys land alongside them. + expect(meta.gw_key).toBe("gw_val"); + expect(meta.reason).toBe("customer request"); + }); + + it("release #1014 review — falsy gateway id keeps the pending_ placeholder and omits gatewayRefundId", async () => { + seedSinglePartyWalletPayment({}); + mockCreateGatewayRefund.mockResolvedValueOnce({ + refundId: "", + amount: 10000, + currency: "INR", + status: "PENDING", + }); + + const result = await refundPayment({ + paymentId: "pay-1", + reason: "id-less gateway ack", + }); + + expect(result.status).toBe("PENDING"); + // Contract: absent, never "". + expect(result.gatewayRefundId).toBeUndefined(); + // Row keeps the placeholder the reconcile cron matches on. + expect(String(state.refunds[0]?.refundId)).toMatch(/^pending_/); + }); + it("gateway throw keeps a pending_ placeholder, runs NO cascade, and surfaces RefundGatewayError", async () => { seedSinglePartyWalletPayment({}); mockCreateGatewayRefund.mockRejectedValueOnce( diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index ac571eda9..f7dde1139 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -206,12 +206,14 @@ export async function POST( ? allSubscriptionSlots : appointment.slotsOfAppointment; - // For SUBSCRIPTION with slotIds, only reschedule the specific slots + // For SUBSCRIPTION/CLASS with slotIds, only reschedule the specific + // slots. CLASS previously fell through to the whole-class branch, so + // a per-session class reschedule silently escalated to every session. if ( - derivedType === "SUBSCRIPTION" && slotIds && slotIds.length > 0 && - appointment.subscription + ((derivedType === "SUBSCRIPTION" && appointment.subscription) || + (derivedType === "CLASS" && appointment.class)) ) { // Filter to only the requested slots from ALL subscription slots slotsToReschedule = allSubscriptionSlots.filter((s) => @@ -243,10 +245,10 @@ export async function POST( // Mark the appropriate slots as tentative if ( - derivedType === "SUBSCRIPTION" && slotIds && slotIds.length > 0 && - appointment.subscription + ((derivedType === "SUBSCRIPTION" && appointment.subscription) || + (derivedType === "CLASS" && appointment.class)) ) { // Individual/multiple session reschedule - mark ALL slots of the affected appointments // (e.g. a 1.5h session has 3 consecutive slots; all must be marked tentative together) diff --git a/app/api/bookings/consultations/route.ts b/app/api/bookings/consultations/route.ts index 55817c019..e4c52b464 100644 --- a/app/api/bookings/consultations/route.ts +++ b/app/api/bookings/consultations/route.ts @@ -1,5 +1,9 @@ import * as Sentry from "@sentry/nextjs"; import prisma from "@/lib/prisma"; +import { + PROFILE_WITH_USER_SELECT, + APPOINTMENT_LIST_SELECT, +} from "@/lib/booking/list-selects"; import { AppointmentStatus } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { transitionConsultationRequest } from "@/lib/booking/transitions"; @@ -35,11 +39,10 @@ export async function GET(request: NextRequest) { // ownership filter and serving every consultant's consultations. if (!isPrivileged(session.user.role)) { if (session.user.role === "CONSULTANT") { - // Consultants can only see their own consultations + // Consultants can only see their own consultations. Filter on the + // plan's scalar FK (indexed) instead of joining consultantProfile. whereClause.consultationPlan = { - consultantProfile: { - id: session.user.consultantProfileId ?? "__none__", - }, + consultantProfileId: session.user.consultantProfileId ?? "__none__", }; } else if (session.user.role === "CONSULTEE") { // Consultees can only see their own consultations @@ -106,36 +109,25 @@ export async function GET(request: NextRequest) { } const [consultations, total] = await Promise.all([ + // #997 Phase 0 — narrow SELECT (see subscriptions route for rationale). prisma.consultation.findMany({ where: whereClause, - include: { + select: { + id: true, + status: true, + requestedAt: true, + bookingSource: true, consultationPlan: { - include: { - consultantProfile: { - include: { - user: { select: { id: true, name: true, email: true, image: true, role: true, phone: true } }, - }, - }, - }, - }, - requestedBy: { - include: { - user: { select: { id: true, name: true, email: true, image: true, role: true, phone: true } }, - }, - }, - appointment: { - include: { - slotsOfAppointment: { - include: { - user: { select: { id: true, name: true, email: true, image: true, role: true, phone: true } }, - }, - orderBy: { - startsAt: "asc", - }, - }, - payment: { select: { id: true, paymentStatus: true, amount: true, currency: true } }, + select: { + id: true, + title: true, + durationInHours: true, + consultantProfileId: true, + consultantProfile: PROFILE_WITH_USER_SELECT, }, }, + requestedBy: PROFILE_WITH_USER_SELECT, + appointment: APPOINTMENT_LIST_SELECT, }, orderBy: { requestedAt: "desc", diff --git a/app/api/bookings/subscriptions/route.ts b/app/api/bookings/subscriptions/route.ts index 8b7b71071..ce0c41df6 100644 --- a/app/api/bookings/subscriptions/route.ts +++ b/app/api/bookings/subscriptions/route.ts @@ -1,5 +1,9 @@ import * as Sentry from "@sentry/nextjs"; import prisma from "@/lib/prisma"; +import { + PROFILE_WITH_USER_SELECT, + APPOINTMENT_LIST_SELECT, +} from "@/lib/booking/list-selects"; import { Prisma, AppointmentStatus } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { addMonths } from "date-fns"; @@ -42,11 +46,10 @@ export async function GET(request: NextRequest) { // ownership filter and serving every consultant's subscriptions. if (!isPrivileged(session.user.role)) { if (session.user.role === "CONSULTANT") { - // Consultants can only see their own subscriptions + // Consultants can only see their own subscriptions. Filter on the + // plan's scalar FK (indexed) instead of joining consultantProfile. whereClause.subscriptionPlan = { - consultantProfile: { - id: session.user.consultantProfileId ?? "__none__", - }, + consultantProfileId: session.user.consultantProfileId ?? "__none__", }; } else if (session.user.role === "CONSULTEE") { // Consultees can only see their own subscriptions @@ -110,44 +113,37 @@ export async function GET(request: NextRequest) { // kind === "all": no additional filter } + // #997 Phase 0 — narrow SELECTs replace the old include tree. The deep + // includes joined consultantProfile.domain/subDomains/tags (M2M) and a + // per-slot user M2M that no list consumer reads, and over-shared user + // PII (email/role/phone) against the #946 allowlist direction. Field + // superset verified across RequestSlotAllocationTab, the Mini tab, + // fetchApprovals, and useEvents consumers. const [subscriptions, total] = await Promise.all([ prisma.subscription.findMany({ where: whereClause, - include: { + select: { + id: true, + status: true, + requestedAt: true, + bookingSource: true, + schedulingPeriodStartsAt: true, + schedulingPeriodEndsAt: true, + schedulingTimezone: true, subscriptionPlan: { - include: { - consultantProfile: { - include: { - user: { select: { id: true, name: true, email: true, image: true, role: true, phone: true } }, - domain: true, - subDomains: true, - tags: true, - }, - }, - }, - }, - requestedBy: { - include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }, - appointments: { - include: { - slotsOfAppointment: { - include: { - user: { select: { id: true, name: true, email: true, image: true, role: true, phone: true } }, - }, - }, - payment: { select: { id: true, paymentStatus: true, amount: true, currency: true } }, + select: { + id: true, + title: true, + callsPerWeek: true, + durationInMonths: true, + sessionDurationInHours: true, + totalSessions: true, + consultantProfileId: true, + consultantProfile: PROFILE_WITH_USER_SELECT, }, }, + requestedBy: PROFILE_WITH_USER_SELECT, + appointments: APPOINTMENT_LIST_SELECT, }, orderBy: { requestedAt: "desc", diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts b/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts index 53b27d9d5..300241193 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts @@ -8,7 +8,6 @@ import { AllocationResult, } from "../utils/allocationAlgorithms"; import { AllocationService } from "../utils/allocationService"; -import type { RawSlotData } from "./useCalendarData"; import { isRecurringEventType } from "@/utils/slotAllocation/types"; import { ValidationResult, @@ -980,18 +979,18 @@ export function useEventSlotAllocation( * Auto-allocate using available slots */ const autoAllocate = useCallback( - async (availableSlots: TimeSlot[]) => { + // #997 Phase 1 — auto-allocation runs SERVER-side (isAuto mode: Redis + // locks, tz-aware caps, initialAllocation guard, idempotent replay). + // The old path fetched the consultant's entire scheduling period of + // availability into the browser and ran AllocationAlgorithms here. + // The parameter is kept for interface stability with UnifiedCalendar. + async (_availableSlots: TimeSlot[]) => { setIsAllocating(true); setAllocationError(null); try { - const sessionDuration = - eventType === "consultation" || eventType === "webinar" - ? options.durationInHours - : options.sessionDurationInHours; - - // Slots are picked inside the algorithm, so the fingerprint covers - // mode + event only: a double-click replays, a later re-run (after a + // Slots are picked server-side, so the fingerprint covers mode + + // event only: a double-click replays, a later re-run (after a // failure changed nothing) also replays, which is safe either way. const attempt = resolveAttemptKey( attemptKeyRef.current, @@ -999,66 +998,52 @@ export function useEventSlotAllocation( ); attemptKeyRef.current = attempt; - const allocationOptions: AllocationOptions = { + const response = await AllocationService.allocateSlots( eventType, eventId, - durationInMonths: options.durationInMonths, - callsPerWeek: options.callsPerWeek, - durationInHours: sessionDuration, - sessionDurationInHours: sessionDuration, - startDate: options.startDate, - endDate: options.endDate, - totalSessions: options.maxTotalCalls, // maxTotalCalls is already totalSessions-aware - pastConfirmedSlotCount: options.pastConfirmedSlotCount, - // Per-day caps so auto-allocate respects the same limit as the manual - // path (subscription 1/day, class 2/day). - maxCallsPerDay: options.maxCallsPerDay, - maxSessionsPerDay: options.maxSessionsPerDay, - schedulingTimezone: options.schedulingTimezone, - idempotencyKey: attempt.key, - initialAllocation: options.initialAllocation || undefined, - }; - - // For recurring events (subscription/class), the calendar UI only - // provides slots for the currently viewed week, but the algorithm - // needs slots spanning the entire scheduling period. Fetch them. - let slotsForAllocation = availableSlots; - if ( - isRecurringEventType(eventType) && - options.startDate && - options.endDate && - options.consultantId - ) { - const fullPeriodData = await AllocationService.fetchAvailabilitySlots( - options.consultantId, - options.startDate, - options.endDate, - ); - const allRawSlots = [ - ...(fullPeriodData.weekly || []), - ...(fullPeriodData.custom || []), - ]; - slotsForAllocation = allRawSlots.map((slot: RawSlotData) => ({ - startTime: new Date(slot.startsAt), - endTime: new Date(slot.endsAt), - isAvailable: - slot.bookingStatus === "available" || - slot.bookingStatus === "partially-booked", - isBooked: slot.bookingStatus === "fully-booked", - })); - } - - const result = await AllocationAlgorithms.autoAllocate( - slotsForAllocation, - allocationOptions, + [], + { + isAuto: true, + idempotencyKey: attempt.key, + initialAllocation: options.initialAllocation || undefined, + }, ); - if (result.success) { - setSelectedSlots(result.selectedSlots); + if (response.success) { + // Reflect the server's picks on the grid (best-effort — the host + // closes the dialog via onSuccess either way). + const pickedSlots: TimeSlot[] = (response.data ?? []) + .flatMap( + (appointment) => + (appointment.slotsOfAppointment as + | { startsAt: string; endsAt: string }[] + | undefined) ?? [], + ) + .map((slot) => ({ + startTime: new Date(slot.startsAt), + endTime: new Date(slot.endsAt), + isAvailable: true, + isBooked: false, + })); + if (pickedSlots.length > 0) { + setSelectedSlots(pickedSlots); + } toast(autoScheduled()); - onSuccess?.(result); + onSuccess?.({ + success: true, + selectedSlots: pickedSlots, + strategy: "server-auto", + }); } else { - handleAllocationFailure(result, "Auto allocation failed"); + handleAllocationFailure( + { + success: false, + selectedSlots: [], + error: response.error, + httpStatus: response.httpStatus, + }, + "Auto allocation failed", + ); } } catch (error) { const errorMessage = diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms.ts b/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms.ts index 1944f699e..645f5d402 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms.ts @@ -267,6 +267,10 @@ export class AllocationAlgorithms { * 3. Smart scoring system for optimal slot selection * 4. Intelligent spacing for recurring events */ + // #997 Phase 1 — product code now calls the SERVER's isAuto mode; this + // client implementation is retained as the test oracle that pins parity + // between auto-picked schedules and the manual validators (see + // mode-parity.test.ts) until phases 2-3 retire the client engine. static async autoAllocate( availableSlots: TimeSlot[], options: AllocationOptions, diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService.ts b/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService.ts index f32ce5952..fe7d10445 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService.ts @@ -232,10 +232,11 @@ export class AllocationService { ): Promise { const slotStrings = slots.map((slot) => slot.startTime.toISOString()); - // Build the request object consistently for all event types + // Build the request object consistently for all event types. Auto mode + // omits `slots` entirely — the server discovers them (#997 Phase 1). const request: AllocationRequest = { isAuto: allocationOptions?.isAuto || false, - slots: slotStrings, + slots: allocationOptions?.isAuto ? undefined : slotStrings, useRequestedSlots: allocationOptions?.useRequestedSlots, initialAllocation: allocationOptions?.initialAllocation, }; diff --git a/lib/booking/list-selects.ts b/lib/booking/list-selects.ts new file mode 100644 index 000000000..0de8008f8 --- /dev/null +++ b/lib/booking/list-selects.ts @@ -0,0 +1,49 @@ +/** + * Shared Prisma SELECT fragments for the booking list endpoints (#997 + * Phase 0). The narrow selects replaced the old include trees, which joined + * consultant domain/subdomain/tag M2Ms and a per-slot user M2M that no list + * consumer reads, and over-shared user PII (email/role/phone) against the + * #946 allowlist direction. Both list routes must stay field-identical for + * their shared consumers, so the fragments live here rather than being + * repeated per route. + */ + +/** Public-safe user identity for list rows (#946 allowlist). */ +export const PUBLIC_USER_SELECT = { + select: { id: true, name: true, image: true }, +} as const; + +/** A profile row reduced to its id and public user identity. Used for both + * the requesting consultee and the plan's consultant profile. */ +export const PROFILE_WITH_USER_SELECT = { + select: { + id: true, + user: PUBLIC_USER_SELECT, + }, +} as const; + +/** Appointment payload for list rows: org tag, ordered slot atoms, and the + * payment identity fields the requests/approvals tables render. */ +export const APPOINTMENT_LIST_SELECT = { + select: { + id: true, + organizationId: true, + slotsOfAppointment: { + select: { + id: true, + startsAt: true, + endsAt: true, + isTentative: true, + }, + orderBy: { startsAt: "asc" }, + }, + payment: { + select: { + id: true, + paymentStatus: true, + amount: true, + currency: true, + }, + }, + }, +} as const; diff --git a/lib/booking/transitions.ts b/lib/booking/transitions.ts index f5c09aee9..d8739b3a8 100644 --- a/lib/booking/transitions.ts +++ b/lib/booking/transitions.ts @@ -117,6 +117,44 @@ export const EVENT_ALLOWED_FROM: Record = { export const CLASS_EVENT_ALLOWED_FROM: Record = EVENT_ALLOWED_FROM; +export async function transitionWebinarEvent( + tx: Pick, + args: { + where: { id: string }; + to: WebinarStatus; + data?: Omit; + fromIn?: WebinarStatus[]; + }, +): Promise { + const res = await tx.webinar.updateMany({ + where: { + ...args.where, + status: { in: args.fromIn ?? EVENT_ALLOWED_FROM[args.to] }, + }, + data: { status: args.to, ...args.data }, + }); + if (res.count === 0) throw new IllegalTransitionError("Webinar", args.to); +} + +export async function transitionClassEvent( + tx: Pick, + args: { + where: { id: string }; + to: ClassStatus; + data?: Omit; + fromIn?: ClassStatus[]; + }, +): Promise { + const res = await tx.class.updateMany({ + where: { + ...args.where, + status: { in: args.fromIn ?? CLASS_EVENT_ALLOWED_FROM[args.to] }, + }, + data: { status: args.to, ...args.data }, + }); + if (res.count === 0) throw new IllegalTransitionError("Class", args.to); +} + //////////////////////////////////////////////// SlotOfAppointment //////////////////////////////////////////////// // A reschedule may re-mark a SCHEDULED or already-RESCHEDULED slot tentative, diff --git a/lib/payments/operations/refund.ts b/lib/payments/operations/refund.ts index 251408cb7..c18d6cb63 100644 --- a/lib/payments/operations/refund.ts +++ b/lib/payments/operations/refund.ts @@ -83,8 +83,11 @@ export type RefundResult = { * gateway accepted the refund but hasn't settled it — the refund webhook * (or the cascadedAt backstop cron) completes it. */ status: "SUCCEEDED" | "PENDING"; - /** Real gateway refund id (re_xxx / rfnd_xxx / mock_re_xxx). */ - gatewayRefundId: string; + /** Real gateway refund id (re_xxx / rfnd_xxx / mock_re_xxx). Absent when + * the gateway accepted but returned no id — the Refund row then keeps its + * `pending_` placeholder and the reconcile cron owns recovery (release + * #1014 review: never surface "" as a gateway id). */ + gatewayRefundId?: string; }; export class RefundValidationError extends Error { @@ -335,9 +338,24 @@ export async function refundPayment(input: RefundInput): Promise { await prisma.refund.update({ where: { id: reserved.id }, data: { - refundId: gateway.refundId, + // Falsy gateway id keeps the pending_ placeholder (mirrors the FAILED + // branch) so the reconcile cron still matches the row — and the + // non-nullable unique column never gets "". + refundId: gateway.refundId || reserved.refundId, + // Merge, not replace: Phase 1's audit keys (initiatedByUserId, source) + // must survive gateway-id binding — the Razorpay path always returns + // notes, so a bare assign wiped them (release #1014 review). ...(gateway.metadata - ? { metadata: gateway.metadata as Prisma.InputJsonValue } + ? { + metadata: { + ...(reserved.metadata && + typeof reserved.metadata === "object" && + !Array.isArray(reserved.metadata) + ? reserved.metadata + : {}), + ...(gateway.metadata as Record), + } as Prisma.InputJsonValue, + } : {}), }, }); @@ -353,7 +371,7 @@ export async function refundPayment(input: RefundInput): Promise { organizationEarningsReversed: 0, clawbackInitiated: false, status: "PENDING" as const, - gatewayRefundId: gateway.refundId, + gatewayRefundId: gateway.refundId || undefined, }; } @@ -380,7 +398,7 @@ export async function refundPayment(input: RefundInput): Promise { amountRefundedPaise: requested, ...cascade, status: "SUCCEEDED" as const, - gatewayRefundId: gateway.refundId, + gatewayRefundId: gateway.refundId || undefined, }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, diff --git a/scripts/appointments/cleanup-tentative-slots.ts b/scripts/appointments/cleanup-tentative-slots.ts index 908e665da..e4ce83af1 100644 --- a/scripts/appointments/cleanup-tentative-slots.ts +++ b/scripts/appointments/cleanup-tentative-slots.ts @@ -65,7 +65,10 @@ async function cleanupTentativeSlotsUnlocked(): Promise a.id)); + const trackedLive = existingUtil.appointmentIds.filter((id) => + liveIds.has(id), + ); + const staleCount = existingUtil.appointmentIds.length - trackedLive.length; + if (staleCount > 0) { + const alreadyTracked = new Set(trackedLive); + const incomingNew = newAppointmentIds.filter( + (id) => !alreadyTracked.has(id), + ); + const substituted = incomingNew.slice(0, staleCount); + idsToDebit = incomingNew.slice(staleCount); + await tx.bookingUtilization.update({ + where: { id: existingUtil.id }, + data: { appointmentIds: [...trackedLive, ...substituted] }, + }); + if (idsToDebit.length === 0) return; + } + } + try { await recordBookingUtilization(tx, { programAssignmentId: assignment.id, paymentId: orgPayment.id, - engagementsConsumed: newAppointmentIds.length, + engagementsConsumed: idsToDebit.length, priceAtBookingPaise, // PR-1e (G3): pass the appointment ids so re-allocation // (delete+recreate of the same slot) can't double-debit. The // helper computes the set diff against // BookingUtilization.appointmentIds and increments only by the // genuinely-new ids. - appointmentIds: newAppointmentIds, + appointmentIds: idsToDebit, }); } catch (err) { if (err instanceof ProgramAssignmentLimitError) { @@ -2432,10 +2461,12 @@ export class SlotAllocationService { case "webinar": // Webinar model does NOT have startDate/endDate fields - // Start date is stored in the Appointment's slots - await tx.webinar.update({ + // Start date is stored in the Appointment's slots. + // Guarded transition — an unguarded update let allocation racing a + // cancel resurrect a CANCELLED (or re-open a COMPLETED) webinar. + await transitionWebinarEvent(tx, { where: { id: eventId }, - data: { status: "SCHEDULED" }, + to: "SCHEDULED", }); break; @@ -2444,10 +2475,11 @@ export class SlotAllocationService { // FIX: Only set schedulingPeriod if not already configured — same guard as SUBSCRIPTION. // Overwriting an explicitly-set period on re-allocation shifts the window, allowing // slots outside the original range to pass the scheduling-period validation check. - await tx.class.update({ + // Guarded transition — same resurrection hazard as WEBINAR above. + await transitionClassEvent(tx, { where: { id: eventId }, + to: "SCHEDULED", data: { - status: "SCHEDULED", ...(!config.schedulingPeriodStartsAt || !config.schedulingPeriodEndsAt ? {