SPB Git

Repository Public

public.ts 13.7 KB 180 lines typescript

1import { z } from "zod";2import { sql } from "../db";3import { ApiError, body, clientIp } from "../http";4import { hmac, safeEqual, sha256 } from "../crypto";5import { mailConfigured, nowMs } from "../env";6import { rateLimit } from "../ratelimit";7import { assertSameOrigin, ownerUser } from "../auth";8import { computeSlots } from "../engine/slots";9import { invitation, feed, type IcsEvent } from "../ics";10import { engineInput, getEventType, getSettings, type EventTypeRow } from "../services/state";11import { resolveToken, submitRequest, visitorAcceptProposal, visitorCancel, visitorReschedule } from "../services/booking";12import { isoInstant, localDate, submitInput, toMs } from "./schemas";13import type { RouteDef } from "./router";1415/** What a visitor may know about an appointment type. `location_detail` is NOT here: it is sent once confirmed. */16const publicType = (t: EventTypeRow) => ({17  slug: t.slug, title: t.title, titleEn: t.title_en, description: t.description, descriptionEn: t.description_en, durationMin: t.duration_min, color: t.color,18  locationKind: t.location_kind, instructions: t.instructions, instructionsEn: t.instructions_en, languages: t.languages, approval: t.approval,19  questions: t.questions, askOrganization: t.ask_organization, requireReason: t.require_reason, horizonDays: t.horizon_days,20});2122const MIN_FILL_MS = 2500, MAX_FORM_AGE_MS = 3 * 3_600_000;23const formToken = () => { const ts = String(Date.now()); return `${ts}.${hmac("form", ts)}`; };24function checkFormToken(token: string) {25  const [ts, mac] = token.split(".");26  if (!ts || !mac || !safeEqual(mac, hmac("form", ts))) throw new ApiError(400, "form_invalid", "The form has expired, reload the page");27  const age = Date.now() - Number(ts);28  if (age > MAX_FORM_AGE_MS) throw new ApiError(400, "form_expired", "The form has expired, reload the page");29  if (age < MIN_FILL_MS && process.env.NODE_ENV === "production") throw new ApiError(400, "form_too_fast", "Please review the form before sending");30}3132async function slotsFor(et: EventTypeRow, from: string, to: string, opts: { ignoreRequestId?: string | null; ignoreBookingId?: string | null } = {}) {33  const settings = await getSettings(et.user_id);34  const f = Temporal.PlainDate.from(from), t = Temporal.PlainDate.from(to);35  if (Temporal.PlainDate.compare(t, f) < 0 || f.until(t).days > 62) throw new ApiError(400, "invalid_range", "Range must be 1 to 62 days");36  const fromMs = f.toZonedDateTime(settings.timezone).epochMilliseconds, toMsV = t.add({ days: 1 }).toZonedDateTime(settings.timezone).epochMilliseconds;37  const state = await engineInput(et, settings.timezone, fromMs, toMsV, { now: nowMs(), ...opts });38  const slots = computeSlots({ ...state, fromDate: from, toDate: to });39  return { timezone: settings.timezone, durationMin: et.duration_min, slots: slots.map((s) => new Date(s.start).toISOString()) };40}4142async function manageView(raw: string) {43  const { requestId } = await resolveToken(raw);44  const [r] = await sql<{ id: string; status: string; starts_at: Date; ends_at: Date; visitor_tz: string; locale: string; proposed_starts_at: Date | null; proposed_ends_at: Date | null; proposal_expires_at: Date | null; proposal_message: string; decline_message: string; booking_id: string | null; event_type_id: string; contact_id: string | null; user_id: string }[]>`45    SELECT id, status, starts_at, ends_at, visitor_tz, locale, proposed_starts_at, proposed_ends_at, proposal_expires_at, proposal_message, decline_message, booking_id, event_type_id, contact_id, user_id46    FROM booking_requests WHERE id = ${requestId}`;47  if (!r) throw new ApiError(404, "link_invalid", "This link is not valid");48  const et = await getEventType({ id: r.event_type_id });49  if (!et) throw new ApiError(404, "link_invalid", "This link is not valid");50  const settings = await getSettings(r.user_id);51  const [b] = r.booking_id ? await sql<{ id: string; status: string; starts_at: Date; ends_at: Date; location_text: string; cancelled_by: string | null }[]>`SELECT id, status, starts_at, ends_at, location_text, cancelled_by FROM bookings WHERE id = ${r.booking_id}` : [];52  const [c] = r.contact_id ? await sql<{ name: string }[]>`SELECT name FROM contacts WHERE id = ${r.contact_id}` : [];53  const [change] = b ? await sql<{ starts_at: Date; status: string }[]>`SELECT starts_at, status FROM booking_requests WHERE reschedule_of_booking_id = ${b.id} AND status IN ('pending', 'reschedule_requested') LIMIT 1` : [];54  const now = nowMs();55  const confirmed = r.status === "approved" && b?.status === "confirmed";56  const future = (b?.starts_at ?? r.starts_at).getTime() > now;57  const state = confirmed ? (future ? "confirmed" : "past") : r.status === "approved" ? "cancelled" : r.status;58  return {59    state, // pending | reschedule_requested | confirmed | past | declined | expired | cancelled60    ownerName: settings.public_name, ownerTimezone: settings.timezone, visitorName: c?.name ?? "", visitorTz: r.visitor_tz, locale: r.locale,61    type: publicType(et),62    startsAt: (b?.starts_at ?? r.starts_at).toISOString(), endsAt: (b?.ends_at ?? r.ends_at).toISOString(),63    proposal: r.status === "reschedule_requested" && r.proposed_starts_at ? { startsAt: r.proposed_starts_at.toISOString(), endsAt: r.proposed_ends_at!.toISOString(), message: r.proposal_message, expiresAt: r.proposal_expires_at?.toISOString() ?? null } : null,64    pendingChange: change ? { startsAt: change.starts_at.toISOString() } : null,65    declineMessage: r.status === "declined" ? r.decline_message : "",66    locationText: confirmed ? b!.location_text : "",67    can: {68      cancel: (r.status === "pending" || r.status === "reschedule_requested") || (confirmed && future && et.allow_visitor_cancel),69      reschedule: ((r.status === "pending" || r.status === "reschedule_requested") && future) || (confirmed && future && et.allow_visitor_reschedule && !change),70      accept: r.status === "reschedule_requested" && !!r.proposal_expires_at && r.proposal_expires_at.getTime() > now,71      ics: confirmed,72    },73    _ids: { requestId: r.id, bookingId: confirmed ? b!.id : null, userId: r.user_id, eventTypeId: et.id },74  };75}76const stripIds = <T extends { _ids: unknown }>(v: T) => { const { _ids, ...rest } = v; void _ids; return rest; };7778/** Public profile and active appointment types. Also used by the server-rendered pages. */79export async function publicProfile() {80  const owner = await ownerUser();81  if (!owner) return { configured: false as const, profile: null, types: [] };82  const s = await getSettings(owner.id);83  const types = await sql<EventTypeRow[]>`SELECT * FROM event_types WHERE user_id = ${owner.id} AND active ORDER BY position, created_at`;84  return {85    configured: true as const,86    profile: { name: s.public_name, headline: s.public_headline, headlineEn: s.public_headline_en, bio: s.public_bio, bioEn: s.public_bio_en, hasAvatar: !!s.avatar_mime, brandColor: s.brand_color, timezone: s.timezone, locale: s.locale,87      // Whether follow-up e-mails can really be sent: the pages never promise an e-mail otherwise.88      emailEnabled: mailConfigured().ok, retentionDays: s.retention_days },89    types: types.map(publicType),90  };91}92export type PublicProfile = Awaited<ReturnType<typeof publicProfile>>;93export type PublicType = ReturnType<typeof publicType>;9495export const publicRoutes: RouteDef<{ ip: string }>[] = [96  ["GET", "profile", async ({ ctx }) => { await rateLimit(`pub:${ctx.ip}`, 240, 60); return publicProfile(); }],97  ["GET", "avatar", async () => {98    const owner = await ownerUser();99    const [a] = owner ? await sql<{ avatar: Buffer | null; avatar_mime: string | null }[]>`SELECT avatar, avatar_mime FROM settings WHERE user_id = ${owner.id}` : [];100    if (!a?.avatar || !a.avatar_mime) throw new ApiError(404, "not_found");101    return new Response(new Uint8Array(a.avatar), { headers: { "Content-Type": a.avatar_mime, "Cache-Control": "public, max-age=300", "X-Content-Type-Options": "nosniff" } });102  }],103  ["GET", "form-token", async ({ ctx }) => { await rateLimit(`pub:${ctx.ip}`, 240, 60); return { formToken: formToken() }; }],104  ["GET", "event-types/:slug/slots", async ({ params, url, ctx }) => {105    await rateLimit(`slots:${ctx.ip}`, 120, 60);106    const q = z.object({ from: localDate, to: localDate }).parse({ from: url.searchParams.get("from"), to: url.searchParams.get("to") });107    const et = await getEventType({ slug: params.slug });108    if (!et || !et.active) throw new ApiError(404, "not_found", "This appointment type does not exist");109    return slotsFor(et, q.from, q.to);110  }],111  ["POST", "requests", async ({ req, ctx }) => {112    assertSameOrigin(req);113    await rateLimit(`submit:h:${ctx.ip}`, Number(process.env.PUBLIC_SUBMIT_PER_HOUR ?? 8), 3600);114    await rateLimit(`submit:d:${ctx.ip}`, Number(process.env.PUBLIC_SUBMIT_PER_DAY ?? 30), 86_400);115    const b = await body(req, submitInput);116    if (b.website) throw new ApiError(400, "form_invalid", "The form could not be accepted");117    checkFormToken(b.formToken);118    const res = await submitRequest({ slug: b.slug, startMs: toMs(b.start), name: b.name, email: b.email, organization: b.organization, reason: b.reason, answers: b.answers, visitorTz: b.visitorTz, locale: b.locale, idempotencyKey: b.idempotencyKey, ip: ctx.ip });119    return { status: res.status, manageToken: res.manageToken, startsAt: res.startsAt, endsAt: res.endsAt };120  }],121  // ── Management link. The token is the only credential: every lookup goes through it, never through an id. ──122  ["GET", "manage/:token", async ({ params, ctx }) => { await rateLimit(`manage:${ctx.ip}`, 60, 60); return stripIds(await manageView(params.token)); }],123  ["GET", "manage/:token/slots", async ({ params, url, ctx }) => {124    await rateLimit(`slots:${ctx.ip}`, 120, 60);125    const q = z.object({ from: localDate, to: localDate }).parse({ from: url.searchParams.get("from"), to: url.searchParams.get("to") });126    const v = await manageView(params.token);127    const et = (await getEventType({ id: v._ids.eventTypeId }))!;128    return slotsFor(et, q.from, q.to, { ignoreRequestId: v._ids.requestId, ignoreBookingId: v._ids.bookingId });129  }],130  ["POST", "manage/:token/cancel", async ({ req, params, ctx }) => {131    assertSameOrigin(req); await rateLimit(`manage:w:${ctx.ip}`, 20, 3600);132    const b = await body(req, z.object({ reason: z.string().trim().max(500).default("") }));133    await visitorCancel(params.token, b.reason, ctx.ip);134    return stripIds(await manageView(params.token));135  }],136  ["POST", "manage/:token/accept", async ({ req, params, ctx }) => {137    assertSameOrigin(req); await rateLimit(`manage:w:${ctx.ip}`, 20, 3600);138    await visitorAcceptProposal(params.token, ctx.ip);139    return stripIds(await manageView(params.token));140  }],141  ["POST", "manage/:token/reschedule", async ({ req, params, ctx }) => {142    assertSameOrigin(req); await rateLimit(`manage:w:${ctx.ip}`, 20, 3600);143    const b = await body(req, z.object({ start: isoInstant }));144    const r = await visitorReschedule(params.token, toMs(b.start), ctx.ip);145    return { result: r.status, ...stripIds(await manageView(params.token)) };146  }],147  ["GET", "manage/:token/ics", async ({ params, ctx }) => {148    await rateLimit(`manage:${ctx.ip}`, 60, 60);149    const v = await manageView(params.token);150    if (!v._ids.bookingId) throw new ApiError(409, "invalid_state", "There is no confirmed appointment");151    const ev = await bookingIcsEvent(v._ids.bookingId);152    return new Response(invitation(ev), { headers: { "Content-Type": "text/calendar; charset=utf-8", "Content-Disposition": 'attachment; filename="rendez-vous.ics"', "Cache-Control": "no-store" } });153  }],154  // Private subscription feed of the owner's confirmed appointments (token set in Settings → Integrations).155  ["GET", "feed/:token", async ({ params, ctx }) => {156    await rateLimit(`feed:${ctx.ip}`, 60, 60);157    const token = params.token.replace(/\.ics$/, "");158    const [s] = await sql<{ user_id: string; public_name: string }[]>`SELECT user_id, public_name FROM settings WHERE feed_token_hash = ${sha256(token)}`;159    if (!s) throw new ApiError(404, "not_found");160    const ids = await sql<{ id: string }[]>`SELECT id FROM bookings WHERE user_id = ${s.user_id} AND status = 'confirmed' AND ends_at > now() - interval '60 days' ORDER BY starts_at LIMIT 2000`;161    const events: IcsEvent[] = [];162    for (const b of ids) events.push(await bookingIcsEvent(b.id));163    return new Response(feed(`SP Calendar — ${s.public_name}`, events), { headers: { "Content-Type": "text/calendar; charset=utf-8", "Cache-Control": "private, max-age=300" } });164  }],165];166167/** ICS view of a booking: title, time, place. Never the reason, the answers or internal notes. */168export async function bookingIcsEvent(bookingId: string): Promise<IcsEvent> {169  const [b] = await sql<{ ics_uid: string; ics_sequence: number; starts_at: Date; ends_at: Date; status: string; title: string; location_text: string; updated_at: Date; name: string | null; email: string | null; public_name: string; owner_email: string; reply_to: string | null }[]>`170    SELECT b.ics_uid, b.ics_sequence, b.starts_at, b.ends_at, b.status, b.title, b.location_text, b.updated_at, c.name, c.email::text, s.public_name, u.email::text AS owner_email, COALESCE(s.reply_to, s.notify_email)::text AS reply_to171    FROM bookings b JOIN users u ON u.id = b.user_id JOIN settings s ON s.user_id = b.user_id LEFT JOIN contacts c ON c.id = b.contact_id WHERE b.id = ${bookingId}`;172  if (!b) throw new ApiError(404, "not_found");173  return {174    uid: b.ics_uid, sequence: b.ics_sequence, start: b.starts_at, end: b.ends_at, status: b.status === "confirmed" ? "CONFIRMED" : "CANCELLED", summary: b.title,175    location: b.location_text || undefined, organizerName: b.public_name, organizerEmail: b.reply_to ?? b.owner_email, attendeeName: b.name ?? undefined, attendeeEmail: b.email ?? undefined, lastModified: b.updated_at,176  };177}178179export const publicGuard = async (req: Request) => ({ ip: clientIp(req) });180
Files main
Menu

Theme