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
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"
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
48 changes: 48 additions & 0 deletions __tests__/payments/refund-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
// 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(
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
50 changes: 21 additions & 29 deletions app/api/bookings/consultations/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading