6539eb4554
feat: iCal calendar feed (GRO-107) Closes GRO-107
127 lines
3.4 KiB
TypeScript
127 lines
3.4 KiB
TypeScript
import { Hono } from "hono";
|
|
import { randomBytes } from "node:crypto";
|
|
import {
|
|
and,
|
|
eq,
|
|
gte,
|
|
getDb,
|
|
appointments,
|
|
clients,
|
|
pets,
|
|
services,
|
|
staff,
|
|
} from "@groombook/db";
|
|
|
|
export const calendarRouter = new Hono();
|
|
|
|
function formatIcalDate(date: Date): string {
|
|
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
}
|
|
|
|
function escapeIcalText(text: string | null): string {
|
|
if (!text) return "";
|
|
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
|
}
|
|
|
|
function buildIcalFeed(
|
|
appointments: Array<{
|
|
id: string;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
status: string;
|
|
clientName: string | null;
|
|
petName: string | null;
|
|
serviceName: string | null;
|
|
}>,
|
|
staffName: string,
|
|
dtstamp: string
|
|
): string {
|
|
const lines: string[] = [
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//GroomBook//EN",
|
|
"CALSCALE:GREGORIAN",
|
|
"METHOD:PUBLISH",
|
|
`X-WR-CALNAME:${escapeIcalText(staffName)} - GroomBook`,
|
|
];
|
|
|
|
for (const appt of appointments) {
|
|
const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED";
|
|
const sequence = appt.status === "cancelled" ? "1" : "0";
|
|
const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`;
|
|
const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`;
|
|
|
|
lines.push(
|
|
"BEGIN:VEVENT",
|
|
`UID:${appt.id}@groombook`,
|
|
`DTSTAMP:${dtstamp}`,
|
|
`DTSTART:${formatIcalDate(new Date(appt.startTime))}`,
|
|
`DTEND:${formatIcalDate(new Date(appt.endTime))}`,
|
|
`SUMMARY:${escapeIcalText(summary)}`,
|
|
`DESCRIPTION:${escapeIcalText(description)}`,
|
|
`STATUS:${status}`,
|
|
`SEQUENCE:${sequence}`,
|
|
"END:VEVENT"
|
|
);
|
|
}
|
|
|
|
lines.push("END:VCALENDAR");
|
|
return lines.join("\r\n");
|
|
}
|
|
|
|
calendarRouter.get("/:staffId.ics", async (c) => {
|
|
const db = getDb();
|
|
const staffId = c.req.param("staffId") as string;
|
|
const token = c.req.query("token") as string;
|
|
|
|
if (!token) {
|
|
return c.text("Unauthorized", 401);
|
|
}
|
|
|
|
const [staffMember] = await db
|
|
.select()
|
|
.from(staff)
|
|
.where(eq(staff.id, staffId))
|
|
.limit(1);
|
|
|
|
if (!staffMember || staffMember.icalToken !== token) {
|
|
return c.text("Unauthorized", 401);
|
|
}
|
|
|
|
const now = new Date();
|
|
const rows = await db
|
|
.select({
|
|
id: appointments.id,
|
|
startTime: appointments.startTime,
|
|
endTime: appointments.endTime,
|
|
status: appointments.status,
|
|
clientId: appointments.clientId,
|
|
petId: appointments.petId,
|
|
serviceId: appointments.serviceId,
|
|
clientName: clients.name,
|
|
petName: pets.name,
|
|
serviceName: services.name,
|
|
})
|
|
.from(appointments)
|
|
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
|
.innerJoin(pets, eq(appointments.petId, pets.id))
|
|
.innerJoin(services, eq(appointments.serviceId, services.id))
|
|
.where(
|
|
and(
|
|
eq(appointments.staffId, staffId),
|
|
gte(appointments.startTime, now)
|
|
)
|
|
)
|
|
.orderBy(appointments.startTime);
|
|
|
|
const ical = buildIcalFeed(rows, staffMember.name, formatIcalDate(new Date()));
|
|
return c.text(ical, 200, {
|
|
"Content-Type": "text/calendar; charset=utf-8",
|
|
"Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`,
|
|
});
|
|
});
|
|
|
|
export function generateIcalToken(): string {
|
|
return randomBytes(32).toString("hex");
|
|
}
|