Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system

- Next.js 16 App Router site with the ANOUMA design system
- Payload CMS (PostgreSQL) for offers, events, posts and page content
- WebRTC video-call system with custom signaling server
- SMTP email reminders and booking-request notifications
- Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
maro
2026-08-25 16:40:51 +02:00
commit 45261a0461
138 changed files with 23263 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { randomInt } from "node:crypto";
// Unambiguous alphabet (no 0/O/1/I/l) — easier to read aloud/type correctly.
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
/**
* Cryptographically random meeting password, e.g. "K7mQ-42Px". Independent
* of the event's id/slug — never derive it from the meeting identifier.
*/
export function generateMeetingPassword(): string {
const chars = Array.from({ length: 8 }, () => ALPHABET[randomInt(ALPHABET.length)]);
return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`;
}
+22
View File
@@ -0,0 +1,22 @@
import type { MeetingRole } from "./token";
export type ParticipantSummary = {
participantId: string;
name: string;
role: MeetingRole;
};
export type ClientToServerMessage =
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
| { type: "kick"; targetParticipantId: string };
export type ServerToClientMessage =
| { type: "welcome"; participantId: string; role: MeetingRole; participants: ParticipantSummary[] }
| ({ type: "peer-joined" } & ParticipantSummary)
| { type: "peer-left"; participantId: string }
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
| { type: "kicked" };
+118
View File
@@ -0,0 +1,118 @@
import { getPayload } from "payload";
import config from "@payload-config";
import { combineDateAndTime } from "./status";
import { sendReminderEmail } from "@/lib/email/sendReminderEmail";
const REMINDER_TOLERANCE_MS = 10 * 60 * 1000; // cron may run every few minutes
function isDue(reminderAt: number, now: number): boolean {
return now >= reminderAt && now < reminderAt + REMINDER_TOLERANCE_MS;
}
function formatDate(date: Date): string {
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
/**
* Checks all upcoming online events for due 60/30-minute reminders and sends
* them — to registered, non-cancelled participants and (optionally) to the
* admin host. Idempotent: every send is guarded by a "...Sent" flag so a
* cron job that runs every few minutes never double-sends.
*/
export async function runEventReminders() {
const payload = await getPayload({ config });
const now = new Date();
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
const windowStart = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const windowEnd = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString();
const { docs: events } = await payload.find({
collection: "events",
where: {
and: [
{ isOnline: { equals: true } },
{ date: { greater_than_equal: windowStart } },
{ date: { less_than_equal: windowEnd } },
],
},
limit: 200,
});
const results = { emailsSent: 0, eventsChecked: events.length };
for (const event of events) {
if (!event.slug) continue;
const start = combineDateAndTime(event.date, event.startTime);
const dateLabel = formatDate(start);
const timeLabel = formatTime(start);
const joinUrl = `${serverUrl}/termine/${event.slug}/beitreten`;
const thresholds: { minutes: 60 | 30; enabled: boolean; participantField: "reminder60Sent" | "reminder30Sent"; hostField: "hostReminder60Sent" | "hostReminder30Sent" }[] = [
{ minutes: 60, enabled: Boolean(event.reminder60Enabled), participantField: "reminder60Sent", hostField: "hostReminder60Sent" },
{ minutes: 30, enabled: Boolean(event.reminder30Enabled), participantField: "reminder30Sent", hostField: "hostReminder30Sent" },
];
for (const threshold of thresholds) {
if (!threshold.enabled) continue;
const reminderAt = start.getTime() - threshold.minutes * 60_000;
if (!isDue(reminderAt, now.getTime())) continue;
// Host reminder (per event, not per registration).
if (event.hostReminderEnabled && !event[threshold.hostField]) {
const { docs: admins } = await payload.find({ collection: "users", limit: 50 });
for (const admin of admins) {
if (!admin.email) continue;
await sendReminderEmail(admin.email, {
recipientName: admin.name || "Team ANOUMA",
eventTitle: event.title,
dateLabel,
timeLabel,
joinUrl,
meetingPassword: event.meetingPassword || "—",
minutesBefore: threshold.minutes,
});
results.emailsSent++;
}
await payload.update({ collection: "events", id: event.id, data: { [threshold.hostField]: true } });
}
// Participant reminders — only registered (not cancelled), not yet sent.
const { docs: registrations } = await payload.find({
collection: "event-registrations",
where: {
and: [
{ event: { equals: event.id } },
{ status: { equals: "registered" } },
{ [threshold.participantField]: { equals: false } },
],
},
limit: 500,
});
for (const registration of registrations) {
await sendReminderEmail(registration.email, {
recipientName: registration.name,
eventTitle: event.title,
dateLabel,
timeLabel,
joinUrl,
meetingPassword: event.meetingPassword || "—",
minutesBefore: threshold.minutes,
});
results.emailsSent++;
await payload.update({
collection: "event-registrations",
id: registration.id,
data: { [threshold.participantField]: true },
});
}
}
}
return results;
}
+76
View File
@@ -0,0 +1,76 @@
import type { WebSocket } from "ws";
import type { MeetingRole } from "./token";
export type Participant = {
ws: WebSocket;
participantId: string;
name: string;
role: MeetingRole;
};
type Room = {
participants: Map<string, Participant>;
kickedParticipantIds: Set<string>;
};
// In-memory only — this server process is the single source of truth for
// signaling (no media is ever stored or relayed through it). If this app is
// ever scaled to multiple instances, this state needs to move to a shared
// store (e.g. Redis pub/sub) alongside a real SFU.
const rooms = new Map<string, Room>();
function getOrCreateRoom(eventSlug: string): Room {
let room = rooms.get(eventSlug);
if (!room) {
room = { participants: new Map(), kickedParticipantIds: new Set() };
rooms.set(eventSlug, room);
}
return room;
}
export function isKicked(eventSlug: string, participantId: string): boolean {
return rooms.get(eventSlug)?.kickedParticipantIds.has(participantId) ?? false;
}
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
const room = getOrCreateRoom(eventSlug);
const existing = [...room.participants.values()];
room.participants.set(participant.participantId, participant);
return existing;
}
export function leaveRoom(eventSlug: string, participantId: string) {
const room = rooms.get(eventSlug);
if (!room) return;
room.participants.delete(participantId);
if (room.participants.size === 0) rooms.delete(eventSlug);
}
export function kickFromRoom(eventSlug: string, participantId: string) {
const room = rooms.get(eventSlug);
if (!room) return;
room.kickedParticipantIds.add(participantId);
room.participants.delete(participantId);
}
export function getParticipant(eventSlug: string, participantId: string): Participant | undefined {
return rooms.get(eventSlug)?.participants.get(participantId);
}
export function getRoomParticipants(eventSlug: string): Participant[] {
return [...(rooms.get(eventSlug)?.participants.values() ?? [])];
}
export function broadcast(
eventSlug: string,
message: unknown,
opts: { exclude?: string } = {},
) {
const room = rooms.get(eventSlug);
if (!room) return;
const data = JSON.stringify(message);
for (const p of room.participants.values()) {
if (opts.exclude && p.participantId === opts.exclude) continue;
if (p.ws.readyState === p.ws.OPEN) p.ws.send(data);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { IncomingMessage } from "node:http";
import type { WebSocket, WebSocketServer } from "ws";
import { verifyMeetingToken } from "./token";
import {
broadcast,
getParticipant,
isKicked,
joinRoom,
kickFromRoom,
leaveRoom,
type Participant,
} from "./rooms";
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
function participantSummary(p: Participant): ParticipantSummary {
return { participantId: p.participantId, name: p.name, role: p.role };
}
export function attachSignalingServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url ?? "", "http://internal");
const token = url.searchParams.get("token") ?? "";
const payload = verifyMeetingToken(token);
if (!payload) {
ws.close(4000, "invalid-token");
return;
}
if (isKicked(payload.eventSlug, payload.participantId)) {
ws.close(4001, "removed-by-host");
return;
}
const self: Participant = {
ws,
participantId: payload.participantId,
name: payload.name,
role: payload.role,
};
const existing = joinRoom(payload.eventSlug, self);
ws.send(
JSON.stringify({
type: "welcome",
participantId: self.participantId,
role: self.role,
participants: existing.map(participantSummary),
}),
);
broadcast(payload.eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
exclude: self.participantId,
});
ws.on("message", (raw) => {
let message: ClientToServerMessage;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
const target = getParticipant(payload.eventSlug, message.to);
if (target && target.ws.readyState === target.ws.OPEN) {
target.ws.send(
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
);
}
return;
}
if (message.type === "kick") {
if (self.role !== "host") return; // server-side authority, not client-claimed
const target = getParticipant(payload.eventSlug, message.targetParticipantId);
if (!target) return;
kickFromRoom(payload.eventSlug, target.participantId);
target.ws.send(JSON.stringify({ type: "kicked" }));
target.ws.close(4001, "removed-by-host");
broadcast(payload.eventSlug, { type: "peer-left", participantId: target.participantId });
}
});
ws.on("close", () => {
leaveRoom(payload.eventSlug, self.participantId);
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
});
});
}
+51
View File
@@ -0,0 +1,51 @@
export type MeetingStatus = "scheduled" | "open" | "live" | "ended";
/**
* Payload's "time only" date fields still store a full ISO timestamp, just
* with an arbitrary date portion — this combines the real event date with
* the hour/minute from a time-only field into one correct Date.
*/
export function combineDateAndTime(dateISO: string, timeISO?: string | null): Date {
const date = new Date(dateISO);
if (!timeISO) return date;
const time = new Date(timeISO);
const combined = new Date(date);
combined.setHours(time.getHours(), time.getMinutes(), 0, 0);
return combined;
}
export type MeetingWindow = {
start: Date;
end: Date;
joinWindowMinutes: number;
closeAfterMinutes: number;
now?: Date;
};
export function getMeetingStatus({
start,
end,
joinWindowMinutes,
closeAfterMinutes,
now = new Date(),
}: MeetingWindow): MeetingStatus {
const openAt = start.getTime() - joinWindowMinutes * 60_000;
const closeAt = end.getTime() + closeAfterMinutes * 60_000;
const t = now.getTime();
if (t >= closeAt) return "ended";
if (t >= start.getTime()) return "live";
if (t >= openAt) return "open";
return "scheduled";
}
export function canJoinMeeting(status: MeetingStatus): boolean {
return status === "open" || status === "live";
}
export const statusLabel: Record<MeetingStatus, string> = {
scheduled: "Online-Termin",
open: "Termin beginnt bald",
live: "Jetzt teilnehmen",
ended: "Termin beendet",
};
+66
View File
@@ -0,0 +1,66 @@
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
export type MeetingRole = "host" | "participant";
export type MeetingTokenPayload = {
eventSlug: string;
participantId: string;
name: string;
role: MeetingRole;
exp: number; // unix ms
};
function secret(): string {
const value = process.env.MEETING_SESSION_SECRET;
if (!value) throw new Error("MEETING_SESSION_SECRET is not set");
return value;
}
function base64url(input: Buffer | string): string {
return Buffer.from(input).toString("base64url");
}
function sign(data: string): string {
return createHmac("sha256", secret()).update(data).digest("base64url");
}
/** Issues a short-lived, self-contained, signed token for one meeting session. */
export function createMeetingToken(args: {
eventSlug: string;
name: string;
role: MeetingRole;
ttlMs?: number;
}): { token: string; participantId: string } {
const participantId = randomUUID();
const payload: MeetingTokenPayload = {
eventSlug: args.eventSlug,
participantId,
name: args.name,
role: args.role,
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
};
const body = base64url(JSON.stringify(payload));
const signature = sign(body);
return { token: `${body}.${signature}`, participantId };
}
/** Verifies signature + expiry. Returns null if invalid/expired/tampered. */
export function verifyMeetingToken(token: string): MeetingTokenPayload | null {
const parts = token.split(".");
if (parts.length !== 2) return null;
const [body, signature] = parts;
const expected = sign(body);
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload;
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
if (!payload.eventSlug || !payload.participantId || !payload.name || !payload.role) return null;
return payload;
} catch {
return null;
}
}