Phase 4
Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres. 14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing. Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically. Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds): A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own). A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice. A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3. Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled. Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
66 files changed
+4599
-107
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { sign, verify } from "../sign";
|
||||
|
||||
describe("sign / verify", () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_SECRET = "test-secret-not-for-production-use-only";
|
||||
});
|
||||
|
||||
it("round-trips a payload", () => {
|
||||
const token = sign('{"serviceIds":["svc_1"]}');
|
||||
expect(verify(token)).toBe('{"serviceIds":["svc_1"]}');
|
||||
});
|
||||
|
||||
it("rejects a tampered payload", () => {
|
||||
const token = sign('{"serviceIds":["svc_1"]}');
|
||||
const [, mac] = token.split(".");
|
||||
const tamperedPayload = Buffer.from('{"serviceIds":["svc_2"]}', "utf8").toString("base64url");
|
||||
expect(verify(`${tamperedPayload}.${mac}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", () => {
|
||||
const token = sign('{"a":1}');
|
||||
const [payload] = token.split(".");
|
||||
expect(verify(`${payload}.not-a-real-signature`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed tokens without throwing", () => {
|
||||
expect(verify("")).toBeNull();
|
||||
expect(verify("no-separator")).toBeNull();
|
||||
expect(verify("...")).toBeNull();
|
||||
expect(verify("!!!not-base64!!!.mac")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a token signed with a different secret", () => {
|
||||
const token = sign('{"a":1}');
|
||||
process.env.AUTH_SECRET = "a-completely-different-secret-value";
|
||||
expect(verify(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("throws clearly if AUTH_SECRET is missing rather than signing with an empty key", () => {
|
||||
delete process.env.AUTH_SECRET;
|
||||
expect(() => sign("payload")).toThrow(/AUTH_SECRET/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { bookingDraftSchema, emptyDraft } from "./schema";
|
||||
import type { BookingDraft } from "./schema";
|
||||
import { sign, verify } from "./sign";
|
||||
|
||||
/**
|
||||
* Server-only I/O around the signed booking-draft cookie. Callable from
|
||||
* Server Components (read) and Server Actions/Route Handlers (read +
|
||||
* write) — `next/headers`'s `cookies()` enforces that boundary itself.
|
||||
*/
|
||||
|
||||
const COOKIE_NAME = "nc_booking_draft";
|
||||
const MAX_AGE_SECONDS = 60 * 60; // 1 hour — long enough to finish a booking, short enough not to linger
|
||||
|
||||
export async function readDraft(businessId: string): Promise<BookingDraft> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(COOKIE_NAME)?.value;
|
||||
if (!raw) return emptyDraft(businessId);
|
||||
|
||||
const payload = verify(raw);
|
||||
if (!payload) return emptyDraft(businessId);
|
||||
|
||||
try {
|
||||
const parsed = bookingDraftSchema.safeParse(JSON.parse(payload));
|
||||
if (!parsed.success || parsed.data.businessId !== businessId) return emptyDraft(businessId);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return emptyDraft(businessId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeDraft(draft: BookingDraft): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify(draft));
|
||||
store.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearDraft(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.delete(COOKIE_NAME);
|
||||
}
|
||||
|
||||
// ── Post-booking confirmation reference ─────────────────────────────────
|
||||
// Deliberately a separate, tiny cookie rather than a URL query param, so
|
||||
// a bookmarked/shared confirmation URL never carries even a non-sensitive
|
||||
// appointment id — see docs/phase-4-summary.md.
|
||||
|
||||
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
|
||||
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
|
||||
|
||||
export async function writeLastBookingReference(appointmentId: string): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify({ appointmentId }));
|
||||
store.set(CONFIRMATION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: CONFIRMATION_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLastBookingReference(): Promise<string | null> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(CONFIRMATION_COOKIE_NAME)?.value;
|
||||
if (!raw) return null;
|
||||
const payload = verify(raw);
|
||||
if (!payload) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as { appointmentId?: unknown };
|
||||
return typeof parsed.appointmentId === "string" ? parsed.appointmentId : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The booking-in-progress draft. Lives in a signed, httpOnly cookie (see
|
||||
* cookie.ts) rather than only in React state, so it survives a page
|
||||
* refresh, browser back/forward, and a sign-in that happens mid-flow —
|
||||
* see docs/phase-4-summary.md for the full reasoning.
|
||||
*/
|
||||
export const guestDetailsSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().min(1, "Phone number is required"),
|
||||
});
|
||||
export type GuestDetails = z.infer<typeof guestDetailsSchema>;
|
||||
|
||||
export const bookingDraftSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).default([]),
|
||||
/** undefined = "any available barber". */
|
||||
staffId: z.string().optional(),
|
||||
date: z.string().optional(), // CalendarDate, "YYYY-MM-DD"
|
||||
startAt: z.iso.datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
guest: guestDetailsSchema.optional(),
|
||||
policyAcknowledged: z.boolean().default(false),
|
||||
/** Set by "Book again" so the services step can show which appointment this draft originated from — display only, never trusted for pricing (see BookingService, which always re-reads current Service prices). */
|
||||
rebookFromAppointmentId: z.string().optional(),
|
||||
});
|
||||
export type BookingDraft = z.infer<typeof bookingDraftSchema>;
|
||||
|
||||
export function emptyDraft(businessId: string): BookingDraft {
|
||||
return {
|
||||
businessId,
|
||||
serviceIds: [],
|
||||
policyAcknowledged: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Tamper-evident (not encrypted) signing for the booking-draft cookie —
|
||||
* an HMAC-SHA256 over the JSON payload using Node's built-in `crypto`,
|
||||
* not a hand-rolled cipher. Signing rather than encrypting is a
|
||||
* deliberate choice: the draft holds data the customer themselves just
|
||||
* typed into a form on this domain (their own name/email/phone, plus
|
||||
* non-sensitive selections), so the realistic threat is tampering
|
||||
* (editing the cookie to book at a different price or under a different
|
||||
* identity), not disclosure to the customer's own browser. httpOnly +
|
||||
* Secure + SameSite=Lax (set in cookie.ts) cover disclosure to anyone
|
||||
* else. Kept as a separate module from cookie.ts so the crypto itself is
|
||||
* unit-testable without Next's `cookies()` API.
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-booking-draft-v1";
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("AUTH_SECRET must be set to sign the booking draft cookie.");
|
||||
}
|
||||
// Derive a purpose-specific key from the shared app secret rather than
|
||||
// reusing it directly, so this cookie's signing key and NextAuth's JWT
|
||||
// key are cryptographically independent even though they start from
|
||||
// the same secret.
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
/** payload.signature, both base64url — a compact, cookie-safe format. */
|
||||
export function sign(payload: string): string {
|
||||
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
||||
return `${encodedPayload}.${mac}`;
|
||||
}
|
||||
|
||||
/** Returns the original payload if the signature is valid, otherwise null. Never throws on malformed input. */
|
||||
export function verify(token: string): string | null {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* v1 is single-business (see docs/implementation-plan.md §11) — every
|
||||
* page that needs "the business" fetches it through here rather than
|
||||
* hardcoding a slug or re-querying independently. Wrapped in React's
|
||||
* `cache()` so multiple Server Components in the same request (a layout
|
||||
* and its page, for instance) share one query instead of issuing it
|
||||
* once each.
|
||||
*/
|
||||
export const getPrimaryBusiness = cache(async () => {
|
||||
return prisma.business.findFirstOrThrow({
|
||||
include: {
|
||||
locations: true,
|
||||
hours: true,
|
||||
settings: true,
|
||||
bookingRule: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
export interface OpeningStatus {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DayHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
opensAtMinutes: number | null;
|
||||
closesAtMinutes: number | null;
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely presentational (read-only "open now?" label for the home page)
|
||||
* — not part of the booking engine's own open/closed check, which
|
||||
* AvailabilityService already performs independently as a real booking
|
||||
* rule. This is display-only, so it lives here rather than in
|
||||
* services/scheduling.
|
||||
*/
|
||||
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
|
||||
const today = todayIn(timezone);
|
||||
const weekday = weekdayOf(today);
|
||||
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
|
||||
|
||||
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
|
||||
return { isOpen: false, label: "Closed today" };
|
||||
}
|
||||
|
||||
const nowMinutes = minutesFromMidnight(new Date(), timezone);
|
||||
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateAppointmentIcs } from "../ics";
|
||||
|
||||
const BASE_INPUT = {
|
||||
appointmentId: "appt_123",
|
||||
businessName: "Blake Heritage Barbershop",
|
||||
businessAddress: "79 Joel Street, HA6 1LW",
|
||||
staffName: "Marcus Reed",
|
||||
serviceNames: ["Haircut & Style"],
|
||||
startAt: new Date("2026-08-10T09:00:00.000Z"),
|
||||
endAt: new Date("2026-08-10T09:30:00.000Z"),
|
||||
};
|
||||
|
||||
describe("generateAppointmentIcs", () => {
|
||||
it("produces a well-formed VCALENDAR/VEVENT block", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/);
|
||||
expect(ics).toContain("BEGIN:VEVENT\r\n");
|
||||
expect(ics).toContain("END:VEVENT\r\n");
|
||||
expect(ics.trimEnd()).toMatch(/END:VCALENDAR$/);
|
||||
});
|
||||
|
||||
it("formats DTSTART/DTEND as UTC instants regardless of local time zone", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics).toContain("DTSTART:20260810T090000Z");
|
||||
expect(ics).toContain("DTEND:20260810T093000Z");
|
||||
});
|
||||
|
||||
it("includes a UID derived from the appointment id, stable across re-generation", () => {
|
||||
const first = generateAppointmentIcs(BASE_INPUT);
|
||||
const second = generateAppointmentIcs(BASE_INPUT);
|
||||
const uidPattern = /UID:appt_123@nextchair/;
|
||||
expect(first).toMatch(uidPattern);
|
||||
expect(second).toMatch(uidPattern);
|
||||
});
|
||||
|
||||
it("combines multiple service names in the summary and description", () => {
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, serviceNames: ["Haircut & Style", "Beard Trim"] });
|
||||
expect(ics).toContain("SUMMARY:Haircut & Style + Beard Trim at Blake Heritage Barbershop");
|
||||
expect(ics).toContain("Services: Haircut & Style\\, Beard Trim");
|
||||
});
|
||||
|
||||
it("escapes commas, semicolons, and backslashes per RFC 5545", () => {
|
||||
const ics = generateAppointmentIcs({
|
||||
...BASE_INPUT,
|
||||
businessName: "Sam's Cuts; Trims, & More\\Co",
|
||||
});
|
||||
// Escaped backslash first, then semicolon, then comma — order matters so
|
||||
// the escaping backslashes themselves don't get re-escaped.
|
||||
expect(ics).toContain("Sam's Cuts\\; Trims\\, & More\\\\Co");
|
||||
});
|
||||
|
||||
it("escapes newlines in notes as literal \\n rather than breaking the content line", () => {
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: "Line one\nLine two" });
|
||||
expect(ics).toContain("Line one\\nLine two");
|
||||
// No raw, unescaped newline should appear inside the DESCRIPTION value itself.
|
||||
const descriptionLine = ics.split("\r\n").find((l) => l.startsWith("DESCRIPTION"));
|
||||
expect(descriptionLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("omits the Notes line entirely when there are no notes", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics).not.toContain("Notes:");
|
||||
});
|
||||
|
||||
it("falls back to just the business name when no address is given", () => {
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, businessAddress: undefined });
|
||||
expect(ics).toContain("LOCATION:Blake Heritage Barbershop");
|
||||
});
|
||||
|
||||
it("folds content lines longer than 75 octets per RFC 5545", () => {
|
||||
const longNotes = "N".repeat(200);
|
||||
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: longNotes });
|
||||
const rawLines = ics.split("\r\n");
|
||||
// No single physical line (before folding continuation) should exceed 75 octets.
|
||||
for (const line of rawLines) {
|
||||
expect(line.length).toBeLessThanOrEqual(75);
|
||||
}
|
||||
// Continuation lines start with a single space, per the spec.
|
||||
const descriptionStart = rawLines.findIndex((l) => l.startsWith("DESCRIPTION"));
|
||||
expect(rawLines[descriptionStart + 1]?.startsWith(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses CRLF line endings throughout", () => {
|
||||
const ics = generateAppointmentIcs(BASE_INPUT);
|
||||
expect(ics.includes("\r\n")).toBe(true);
|
||||
// Every bare \n should be immediately preceded by \r.
|
||||
for (let i = 0; i < ics.length; i++) {
|
||||
if (ics[i] === "\n") {
|
||||
expect(ics[i - 1]).toBe("\r");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* RFC 5545 (.ics) generation — provider-independent, works with every
|
||||
* calendar app without any Google/Apple API access, per the Phase 4
|
||||
* brief ("Do not require Google Calendar API access for the basic
|
||||
* feature"). Pure string formatting, no I/O — the caller (a route
|
||||
* handler) supplies already-validated, already-loaded data.
|
||||
*
|
||||
* Time zone handling: DTSTART/DTEND are emitted as UTC instants
|
||||
* (`YYYYMMDDTHHMMSSZ`). This is the simplest *correct* approach — every
|
||||
* calendar app converts a UTC-suffixed time to the viewer's own local
|
||||
* time zone automatically, so there's no VTIMEZONE block to get wrong
|
||||
* and no ambiguity around DST for whoever's viewing the event.
|
||||
*/
|
||||
|
||||
export interface AppointmentIcsInput {
|
||||
appointmentId: string;
|
||||
businessName: string;
|
||||
businessAddress?: string;
|
||||
staffName: string;
|
||||
serviceNames: string[];
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function pad(n: number, width = 2): string {
|
||||
return String(n).padStart(width, "0");
|
||||
}
|
||||
|
||||
function formatUtc(date: Date): string {
|
||||
return (
|
||||
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
|
||||
`T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
|
||||
);
|
||||
}
|
||||
|
||||
/** Escapes text per RFC 5545 §3.3.11 — backslash, semicolon, comma, then newlines. Order matters: backslash must be escaped first, or the escaping backslashes we add for the other characters would themselves get re-escaped. */
|
||||
function escapeText(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
|
||||
}
|
||||
|
||||
/** Folds a content line at 75 octets per RFC 5545 §3.1, continuation lines prefixed with a single space. */
|
||||
function foldLine(line: string): string {
|
||||
const CRLF_FOLD = "\r\n ";
|
||||
if (line.length <= 75) return line;
|
||||
|
||||
const chunks: string[] = [];
|
||||
let rest = line;
|
||||
let first = true;
|
||||
while (rest.length > 0) {
|
||||
const limit = first ? 75 : 74; // continuation lines lose one column to the leading space
|
||||
chunks.push(rest.slice(0, limit));
|
||||
rest = rest.slice(limit);
|
||||
first = false;
|
||||
}
|
||||
return chunks.join(CRLF_FOLD);
|
||||
}
|
||||
|
||||
export function generateAppointmentIcs(input: AppointmentIcsInput): string {
|
||||
const summary = escapeText(`${input.serviceNames.join(" + ")} at ${input.businessName}`);
|
||||
const descriptionParts = [
|
||||
`Barber: ${input.staffName}`,
|
||||
`Services: ${input.serviceNames.join(", ")}`,
|
||||
`Booking reference: ${input.appointmentId}`,
|
||||
];
|
||||
if (input.notes) descriptionParts.push(`Notes: ${input.notes}`);
|
||||
const description = escapeText(descriptionParts.join("\n"));
|
||||
const location = input.businessAddress ? escapeText(`${input.businessName}, ${input.businessAddress}`) : escapeText(input.businessName);
|
||||
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//NextChair//Booking//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${input.appointmentId}@nextchair`,
|
||||
`DTSTAMP:${formatUtc(new Date())}`,
|
||||
`DTSTART:${formatUtc(input.startAt)}`,
|
||||
`DTEND:${formatUtc(input.endAt)}`,
|
||||
`SUMMARY:${summary}`,
|
||||
`DESCRIPTION:${description}`,
|
||||
`LOCATION:${location}`,
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
];
|
||||
|
||||
return lines.map(foldLine).join("\r\n") + "\r\n";
|
||||
}
|
||||
Reference in new issue
Block a user