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:
@@ -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("")}`;
|
||||
}
|
||||
@@ -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" };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user