Files
maroandClaude Sonnet 5 1cd15aff25 Add email verification, personal calendar feed, and full SEO implementation
- Customer accounts now require email verification (hashed, single-use,
  time-limited tokens) before they can request/confirm bookings, with
  resend flows on login/account/booking widget and rate limiting.
- Admins get a private, rotatable iCalendar (ICS) subscription feed of
  their confirmed bookings and public events, timezone-correct for
  Europe/Berlin including DST, never exposing meeting passwords.
- Adds a full SEO layer: per-page canonical/OG/Twitter metadata with
  CMS-editable overrides and content-derived fallbacks, a dynamic
  sitemap.xml and robots.txt driven by real published content, JSON-LD
  (Organization/LocalBusiness, WebSite, WebPage, BreadcrumbList, Service,
  Event, BlogPosting) that never fabricates data, and a CMS-managed
  redirect table for changed slugs.
- Global ANOUMA-naming audit: the brand name is never used to label
  personal account/calendar areas anywhere in the app, CMS, or emails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:57:31 +02:00

60 lines
2.1 KiB
TypeScript

import { NextResponse } from "next/server";
import { getCMS } from "@/lib/payload/getPayload";
import { hashCalendarFeedToken } from "@/lib/calendar/feedToken";
import { buildICSCalendar } from "@/lib/calendar/ics";
import { getAdminFeedICSEvents } from "@/lib/calendar/adminFeed";
export const dynamic = "force-dynamic";
const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
type Args = { params: Promise<{ tokenFile: string }> };
/**
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
* itself is the only credential (there's no login here), so it's treated
* like a secret: looked up by hash only, never logged, and the response is
* marked non-cacheable-by-proxies and non-indexable.
*/
export async function GET(_request: Request, { params }: Args) {
const { tokenFile } = await params;
if (!tokenFile.endsWith(".ics")) {
return new NextResponse("Not found", { status: 404 });
}
const token = tokenFile.slice(0, -".ics".length);
if (!TOKEN_SHAPE.test(token)) {
return new NextResponse("Not found", { status: 404 });
}
const payload = await getCMS();
const hash = hashCalendarFeedToken(token);
const { docs } = await payload.find({
collection: "users",
where: { calendarFeedTokenHash: { equals: hash } },
limit: 1,
});
// Section 15 / later extension: once normal user accounts also get a
// personal feed, a matching lookup against "customers" would go here,
// returning that customer's own bookings instead of the admin feed below.
const owner = docs[0];
if (!owner) {
return new NextResponse("Not found", { status: 404, headers: { "X-Robots-Tag": "noindex, nofollow" } });
}
const events = await getAdminFeedICSEvents(payload);
const ics = buildICSCalendar({ calendarName: "Persönlicher Kalender", events });
return new NextResponse(ics, {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="kalender.ics"',
"X-Robots-Tag": "noindex, nofollow",
"Cache-Control": "private, no-store",
},
});
}