Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/send-appointment-reminders.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +8 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict workflow permissions and prevent concurrent executions.

Address static analysis warnings and follow best practices by making these workflow-level improvements:

  1. Permissions: Explicitly declare permissions: contents: read to adhere to the principle of least privilege.
  2. Concurrency: Add a concurrency group to prevent overlapping workflow runs (which complements your Redis SET-NX lock).
  3. Job Name: Add a name to the job for better display in the GitHub Actions UI.

As per static analysis hints, the workflow defaults to overly broad permissions, lacks job-level concurrency limits, and contains a job definition without a name.

🔒 Proposed fixes for workflow definition
   workflow_dispatch: # Allow manual triggering
 
+permissions:
+  contents: read
+
+concurrency:
+  group: ${{ github.workflow }}
+
 jobs:
   send-appointment-reminders:
+    name: Send Appointment Reminders
     runs-on: ubuntu-latest
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
workflow_dispatch: # Allow manual triggering
jobs:
send-appointment-reminders:
runs-on: ubuntu-latest
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
jobs:
send-appointment-reminders:
name: Send Appointment Reminders
runs-on: ubuntu-latest
🧰 Tools
🪛 zizmor (1.26.1)

[info] 11-11: workflow or action definition without a name (anonymous-definition): this job

(anonymous-definition)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/send-appointment-reminders.yml around lines 8 - 12, Update
the workflow around the top-level workflow_dispatch and
send-appointment-reminders job: declare workflow permissions with contents read
only, add a concurrency group to prevent overlapping runs, and set a descriptive
name on the send-appointment-reminders job for GitHub Actions display.

Source: Linters/SAST tools


🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict workflow permissions and prevent concurrent executions.

Address static analysis warnings and follow best practices by making these workflow-level improvements:

  1. Permissions: Explicitly declare permissions: contents: read to adhere to the principle of least privilege.
  2. Concurrency: Add a concurrency group to prevent overlapping workflow runs (which complements your Redis SET-NX lock).
  3. Job Name: Add a name to the job for better display in the GitHub Actions UI.

As per static analysis hints, the workflow defaults to overly broad permissions, lacks job-level concurrency limits, and contains a job definition without a name.

🔒 Proposed fixes for workflow definition
   workflow_dispatch: # Allow manual triggering
 
+permissions:
+  contents: read
+
+concurrency:
+  group: ${{ github.workflow }}
+
 jobs:
   send-appointment-reminders:
+    name: Send Appointment Reminders
     runs-on: ubuntu-latest
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
workflow_dispatch: # Allow manual triggering
jobs:
send-appointment-reminders:
runs-on: ubuntu-latest
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
jobs:
send-appointment-reminders:
name: Send Appointment Reminders
runs-on: ubuntu-latest
🧰 Tools
🪛 zizmor (1.26.1)

[info] 11-11: workflow or action definition without a name (anonymous-definition): this job

(anonymous-definition)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/send-appointment-reminders.yml around lines 8 - 12, Update
the workflow definition around the send-appointment-reminders job by explicitly
setting workflow-level permissions to contents: read, adding a concurrency group
that prevents overlapping runs, and assigning a descriptive name to the
send-appointment-reminders job for GitHub Actions display.

Source: Linters/SAST tools

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"
Comment on lines +27 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Secure Action references and checkout credentials.

To mitigate supply chain risks and address static analysis errors/warnings:

  1. Pin actions: Pin actions/checkout and actions/setup-node to exact commit SHAs instead of mutable version tags (like @v5).
  2. Persist credentials: Set persist-credentials: false on the checkout action to prevent the workflow from unnecessarily saving the GitHub token in the local Git configuration.

As per static analysis hints, actions are not pinned to a hash, and there is credential persistence through GitHub Actions artifacts because persist-credentials: false is not set.

🔒 Proposed fixes for action references
       - name: Checkout code
-        uses: actions/checkout@v5
+        uses: actions/checkout@<commit-sha> # e.g., actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
 
       - name: Setup Node.js
-        uses: actions/setup-node@v5
+        uses: actions/setup-node@<commit-sha>
         with:
           node-version: "22"
           cache: "npm"
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 27-28: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 28-28: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 31-31: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/send-appointment-reminders.yml around lines 27 - 34,
Update the workflow’s “Checkout code” and “Setup Node.js” steps to reference
immutable, full-length commit SHAs instead of the mutable `@v5` tags, and add
persist-credentials: false to the checkout step’s with configuration.

Source: Linters/SAST tools


🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Secure Action references and checkout credentials.

To mitigate supply chain risks and address static analysis errors/warnings:

  1. Pin actions: Pin actions/checkout and actions/setup-node to exact commit SHAs instead of mutable version tags (like @v5).
  2. Persist credentials: Set persist-credentials: false on the checkout action to prevent the workflow from unnecessarily saving the GitHub token in the local Git configuration.

As per static analysis hints, actions are not pinned to a hash, and there is credential persistence through GitHub Actions artifacts because persist-credentials: false is not set.

🔒 Proposed fixes for action references
       - name: Checkout code
-        uses: actions/checkout@v5
+        uses: actions/checkout@<commit-sha> # e.g., actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
 
       - name: Setup Node.js
-        uses: actions/setup-node@v5
+        uses: actions/setup-node@<commit-sha>
         with:
           node-version: "22"
           cache: "npm"
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 27-28: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 28-28: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 31-31: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/send-appointment-reminders.yml around lines 27 - 34,
Update the checkout and Node.js setup steps in the workflow to reference
immutable, exact commit SHAs instead of the mutable `@v5` tags. Add
persist-credentials: false to the actions/checkout configuration, while
preserving the existing Node.js version and npm cache settings.

Source: Linters/SAST tools


- 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"
4 changes: 4 additions & 0 deletions __tests__/booking-algorithm/rescheduleCancel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe("SCHEDULE_CONFLICT");

Check failure on line 326 in __tests__/booking-algorithm/rescheduleCancel.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
}
});

Expand Down Expand Up @@ -352,7 +352,7 @@
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.notes).toBe(

Check failure on line 355 in __tests__/booking-algorithm/rescheduleCancel.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
"Need to cancel due to scheduling conflict",
);
}
Expand Down Expand Up @@ -1170,6 +1170,7 @@
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: {
Expand Down Expand Up @@ -1205,6 +1206,7 @@
endsAt: new Date(),
isTentative: true,
createdAt: new Date("2024-12-01"),
updatedAt: new Date("2024-12-01"),
appointment: { payment: [], consultation: null, subscription: null },
},
{
Expand All @@ -1214,6 +1216,7 @@
endsAt: new Date(),
isTentative: true,
createdAt: new Date("2024-12-01"),
updatedAt: new Date("2024-12-01"),
appointment: { payment: [], consultation: null, subscription: null },
},
{
Expand All @@ -1223,6 +1226,7 @@
endsAt: new Date(),
isTentative: true,
createdAt: new Date("2024-12-01"),
updatedAt: new Date("2024-12-01"),
appointment: { payment: [], consultation: null, subscription: null },
},
];
Expand Down
45 changes: 28 additions & 17 deletions __tests__/booking-algorithm/slotAllocationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
AppointmentsType,
AppointmentStatus,
} from "@prisma/client";
import {

Check failure on line 73 in __tests__/booking-algorithm/slotAllocationService.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Mocks should not be manually imported from a __mocks__ directory. Instead use `jest.mock` and import from the original module path
makeWeeklyAvailabilitySlot,
makeCustomAvailabilitySlot,
} from "./__mocks__/booking.mockData";
Expand All @@ -91,8 +91,17 @@
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" }),
Expand Down Expand Up @@ -1128,9 +1137,14 @@
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" }),
}),
);
Expand Down Expand Up @@ -1382,7 +1396,7 @@
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();
Expand All @@ -1406,10 +1420,11 @@
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();
});
});

Expand Down Expand Up @@ -2183,14 +2198,10 @@

// 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();
}
});
});
Expand Down
1 change: 1 addition & 0 deletions __tests__/booking/cleanup-tentative-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
12 changes: 7 additions & 5 deletions app/api/appointments/[appointmentId]/reschedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions lib/booking/transitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,44 @@ export const EVENT_ALLOWED_FROM: Record<WebinarStatus, WebinarStatus[]> = {
export const CLASS_EVENT_ALLOWED_FROM: Record<ClassStatus, ClassStatus[]> =
EVENT_ALLOWED_FROM;

export async function transitionWebinarEvent(
tx: Pick<Tx, "webinar">,
args: {
where: { id: string };
to: WebinarStatus;
data?: Omit<Prisma.WebinarUncheckedUpdateManyInput, "status">;
fromIn?: WebinarStatus[];
},
): Promise<void> {
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<Tx, "class">,
args: {
where: { id: string };
to: ClassStatus;
data?: Omit<Prisma.ClassUncheckedUpdateManyInput, "status">;
fromIn?: ClassStatus[];
},
): Promise<void> {
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,
Expand Down
34 changes: 32 additions & 2 deletions scripts/appointments/cleanup-tentative-slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ async function cleanupTentativeSlotsUnlocked(): Promise<TentativeSlotCleanupResu
const staleTentativeSlots = await prisma.slotOfAppointment.findMany({
where: {
isTentative: true,
createdAt: { lt: expirationDate },
// Grace runs from the LAST write, not creation: a reschedule flips
// isTentative on an old row, and measuring from createdAt gave those
// slots zero grace before deletion.
updatedAt: { lt: expirationDate },
appointment: {
payment: {
none: {
Expand Down Expand Up @@ -98,6 +101,31 @@ async function cleanupTentativeSlotsUnlocked(): Promise<TentativeSlotCleanupResu
},
],
},
// Group events: a SCHEDULED/IN_PROGRESS webinar or class with
// tentative slots is mid-reschedule awaiting a new time — the
// guard set above only covered request-status event types, so
// these were swept (dropping attendee links) within the grace
// window of any unpaid event.
{
OR: [
{ webinar: null },
{
webinar: {
status: { notIn: ["SCHEDULED", "IN_PROGRESS"] },
},
},
],
},
{
OR: [
{ class: null },
{
class: {
status: { notIn: ["SCHEDULED", "IN_PROGRESS"] },
},
},
],
},
],
},
},
Expand Down Expand Up @@ -133,7 +161,9 @@ async function cleanupTentativeSlotsUnlocked(): Promise<TentativeSlotCleanupResu
for (const slot of staleTentativeSlots) {
console.log(`\nProcessing tentative slot ${slot.id}`);
console.log(` Appointment ID: ${slot.appointmentId}`);
console.log(` Created: ${slot.createdAt.toISOString()}`);
console.log(
` Created: ${slot.createdAt.toISOString()} (last write ${slot.updatedAt.toISOString()})`,
);
console.log(
` Slot time: ${slot.startsAt.toISOString()} - ${slot.endsAt.toISOString()}`,
);
Expand Down
48 changes: 40 additions & 8 deletions utils/slotAllocation/SlotAllocationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
ALLOCATION_APPROVABLE_FROM,
transitionConsultationRequest,
transitionSubscriptionRequest,
transitionWebinarEvent,
transitionClassEvent,
} from "@/lib/booking/transitions";
import { IllegalTransitionError } from "@/lib/enterprise/transitions";
import {
Expand Down Expand Up @@ -2070,22 +2072,49 @@ export class SlotAllocationService {
// helper's upsert preserves the first-create priceAtBookingPaise.
const existingUtil = await tx.bookingUtilization.findUnique({
where: { paymentId: orgPayment.id },
select: { id: true },
select: { id: true, appointmentIds: true },
});
const priceAtBookingPaise = existingUtil ? 0 : orgPayment.amount;

// Re-allocation deletes counted appointments and recreates them with
// fresh ids, so an id-set diff alone re-debits every replaced session.
// Substitute stale tracked ids (no longer live on this subscription)
// with incoming ids 1:1 WITHOUT debiting; only ids beyond the
// substitution budget are genuinely additional sessions.
let idsToDebit = newAppointmentIds;
if (existingUtil) {
const liveIds = new Set(subscription!.appointments.map((a) => 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) {
Expand Down Expand Up @@ -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;

Expand All @@ -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
? {
Expand Down
Loading