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
2 changes: 2 additions & 0 deletions app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { default as default_createorderview01234567890abcdef } from '@/component
import { default as default_ae6d727f69bea18ad23ef405314d6459 } from '@/components/admin/PendingVerificationView'
import { default as default_357e94cb16882b41b62ce8427705d759 } from '@/components/admin/NotifyTimeslotsView'
import { default as default_f0bf1af8c7fcbee7729125193a4368fe } from '@/components/admin/ScheduleCalendarView'
import { default as default_deletetimeslotbtn0123456789abcdef } from '@/components/admin/DeleteTimeslotButton'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'

/** @type import('payload').ImportMap */
Expand Down Expand Up @@ -72,5 +73,6 @@ export const importMap = {
"@/components/admin/PendingVerificationView#default": default_ae6d727f69bea18ad23ef405314d6459,
"@/components/admin/NotifyTimeslotsView#default": default_357e94cb16882b41b62ce8427705d759,
"@/components/admin/ScheduleCalendarView#default": default_f0bf1af8c7fcbee7729125193a4368fe,
"@/components/admin/DeleteTimeslotButton#default": default_deletetimeslotbtn0123456789abcdef,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
200 changes: 200 additions & 0 deletions app/api/admin/timeslots/[id]/delete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { type NextRequest, NextResponse } from "next/server";
import { sendTimeslotDeletedEmail } from "../../../../../../lib/email";
import { getPayloadClient } from "../../../../../../lib/payload";

export const runtime = "nodejs";
// Notifying many customers by email can take a little while.
export const maxDuration = 60;

interface OrderFileShape {
fileName: string;
pageCount: number;
copies: number;
colorMode: string;
paperSize: string;
doubleSided: boolean;
}

interface PricingShape {
subtotal: number;
tax: number;
total: number;
}

/**
* GET /api/admin/timeslots/[id]/delete
*
* Admin-only preview endpoint. Returns how many orders are booked into this
* timeslot so the delete confirmation UI can tell the admin how many customers
* will be notified, plus a human-readable label for the slot.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const payload = await getPayloadClient();

const { user } = await payload.auth({ headers: request.headers });
if (!user) {
return NextResponse.json(
{ error: "Admin authentication required." },
{ status: 401 },
);
}

const timeslot = await payload
.findByID({ collection: "timeslots", id })
.catch(() => null);

if (!timeslot) {
return NextResponse.json(
{ error: "Timeslot not found." },
{ status: 404 },
);
}

const { totalDocs } = await payload.find({
collection: "orders",
where: { pickupTimeslot: { equals: id } },
limit: 0,
depth: 0,
});

return NextResponse.json({
affectedOrders: totalDocs,
label:
(timeslot as { label?: string }).label ||
`${(timeslot as { date?: string }).date ?? ""} ${
(timeslot as { startTime?: string }).startTime ?? ""
}`.trim(),
});
} catch (error) {
console.error("Error loading timeslot delete preview:", error);
return NextResponse.json(
{ error: "Failed to load timeslot details." },
{ status: 500 },
);
}
}

/**
* POST /api/admin/timeslots/[id]/delete
*
* Admin-only endpoint that deletes a timeslot and notifies every customer with
* an order booked into it. The admin supplies a personalised message (shown in
* the email as "A note from us"). Each affected order has its pickupTimeslot
* reference cleared so the customer can select a new slot.
*
* Body: `{ "message"?: string }`
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const payload = await getPayloadClient();

const { user } = await payload.auth({ headers: request.headers });
if (!user) {
return NextResponse.json(
{ error: "Admin authentication required." },
{ status: 401 },
);
}

const body = (await request.json().catch(() => ({}))) as {
message?: string;
};
const customMessage = body.message?.trim() || undefined;

// Confirm the timeslot exists before doing any work.
const timeslot = await payload
.findByID({ collection: "timeslots", id })
.catch(() => null);
if (!timeslot) {
return NextResponse.json(
{ error: "Timeslot not found." },
{ status: 404 },
);
}

// Find all orders booked into this timeslot (populate the customer).
const { docs: orders } = await payload.find({
collection: "orders",
where: { pickupTimeslot: { equals: id } },
depth: 1,
limit: 0,
});

let notified = 0;

// Clear each order's reference and email the customer.
const results = await Promise.allSettled(
orders.map(async (order) => {
try {
await payload.update({
collection: "orders",
id: order.id,
data: { pickupTimeslot: null },
});
} catch (error) {
console.error(
`[Timeslots] Failed to clear pickupTimeslot on order ${order.orderNumber}:`,
error,
);
}

const customer = order.customer as {
email?: string;
name?: string;
} | null;
if (!customer?.email) return;

await sendTimeslotDeletedEmail({
to: customer.email,
customerName: customer.name || "Customer",
orderNumber: order.orderNumber,
files: (order.files || []) as OrderFileShape[],
pricing: order.pricing as PricingShape | undefined,
customMessage,
});
notified += 1;
}),
);

for (const result of results) {
if (result.status === "rejected") {
console.error(
"[Timeslots] Failed to notify a customer of deletion:",
result.reason,
);
}
}

// Finally, delete the timeslot itself.
await payload.delete({ collection: "timeslots", id });

console.log(
`[Timeslots] Deleted timeslot ${id}; notified ${notified}/${orders.length} order(s).`,
);

return NextResponse.json({
success: true,
deleted: true,
affectedOrders: orders.length,
notified,
});
} catch (error) {
console.error("Error deleting timeslot:", error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Failed to delete timeslot.",
},
{ status: 500 },
);
}
}
92 changes: 7 additions & 85 deletions collections/Timeslots.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import type {
CollectionAfterChangeHook,
CollectionAfterDeleteHook,
CollectionConfig,
} from "payload";
import {
sendTimeslotChangedEmail,
sendTimeslotDeletedEmail,
} from "../lib/email";
import type { CollectionAfterChangeHook, CollectionConfig } from "payload";
import { sendTimeslotChangedEmail } from "../lib/email";
import { getPayloadClient } from "../lib/payload";

/**
Expand Down Expand Up @@ -104,81 +97,6 @@ const notifyOnTimeslotChange: CollectionAfterChangeHook = async ({
return doc;
};

// ── afterDelete hook: notify customers when a booked timeslot is deleted ──
const notifyOnTimeslotDelete: CollectionAfterDeleteHook = async ({
id,
req,
}) => {
// Fire-and-forget so the admin UI isn't blocked
void (async () => {
try {
const payload = req.payload ?? (await getPayloadClient());

// Find all orders that still reference the deleted timeslot
const { docs: orders } = await payload.find({
collection: "orders",
where: { pickupTimeslot: { equals: id } },
depth: 1, // populate the customer relationship
limit: 0, // no limit — fetch all
});

if (orders.length === 0) return;

// Clear the dangling timeslot reference and email each customer
await Promise.allSettled(
orders.map(async (order) => {
// Remove the reference so the customer can re-select a slot
try {
await payload.update({
collection: "orders",
id: order.id,
data: { pickupTimeslot: null },
});
} catch (error) {
console.error(
`[Timeslots] Failed to clear pickupTimeslot on order ${order.orderNumber}:`,
error,
);
}

const customer = order.customer as {
email?: string;
name?: string;
} | null;

if (!customer?.email) return;

await sendTimeslotDeletedEmail({
to: customer.email,
customerName: customer.name || "Customer",
orderNumber: order.orderNumber,
files: (order.files || []) as {
fileName: string;
pageCount: number;
copies: number;
colorMode: string;
paperSize: string;
doubleSided: boolean;
}[],
pricing: order.pricing as
| { subtotal: number; tax: number; total: number }
| undefined,
});
}),
);

console.log(
`[Timeslots] Notified ${orders.length} order(s) about deleted timeslot ${id}`,
);
} catch (error) {
console.error(
`[Timeslots] Failed to send timeslot-deleted emails for ${id}:`,
error,
);
}
})();
};

export const Timeslots: CollectionConfig = {
slug: "timeslots",
admin: {
Expand All @@ -194,6 +112,11 @@ export const Timeslots: CollectionConfig = {
],
description:
"Pickup time windows. Created automatically from Schedules or manually for one-off slots.",
components: {
edit: {
beforeDocumentControls: ["@/components/admin/DeleteTimeslotButton"],
},
},
},
access: {
// Public read so customers can fetch available timeslots
Expand All @@ -205,7 +128,6 @@ export const Timeslots: CollectionConfig = {
},
hooks: {
afterChange: [notifyOnTimeslotChange],
afterDelete: [notifyOnTimeslotDelete],
},
fields: [
{
Expand Down
Loading
Loading