feat(events): add calendar email support
Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
const CRLF = '\r\n';
|
||||
const MAX_OCTETS = 75;
|
||||
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
|
||||
export type CalendarEvent = {
|
||||
uid: string;
|
||||
start: string | Date;
|
||||
end?: string | Date | null;
|
||||
allDay?: boolean;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED';
|
||||
};
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n')
|
||||
.replace(CONTROL_CHARS, '');
|
||||
}
|
||||
|
||||
function foldLine(line: string): string {
|
||||
if (Buffer.byteLength(line, 'utf8') <= MAX_OCTETS) return line;
|
||||
|
||||
const folded: string[] = [];
|
||||
let current = '';
|
||||
let octets = 0;
|
||||
|
||||
for (const character of line) {
|
||||
const size = Buffer.byteLength(character, 'utf8');
|
||||
if (octets + size > MAX_OCTETS) {
|
||||
folded.push(current);
|
||||
current = ' ';
|
||||
octets = 1;
|
||||
}
|
||||
current += character;
|
||||
octets += size;
|
||||
}
|
||||
|
||||
folded.push(current);
|
||||
return folded.join(CRLF);
|
||||
}
|
||||
|
||||
function property(name: string, value: string): string {
|
||||
return foldLine(`${name}:${value}`);
|
||||
}
|
||||
|
||||
function parseDate(value: string | Date | null | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function toUtcStamp(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
function toDateValue(date: Date): string {
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
export function buildEventCalendar(event: CalendarEvent): string {
|
||||
const start = parseDate(event.start);
|
||||
const end = parseDate(event.end);
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
property('PRODID', '-//SHARP-ER//Events Calendar//EN'),
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
property('X-WR-CALNAME', 'SHARP-ER'),
|
||||
];
|
||||
|
||||
if (start) {
|
||||
lines.push('BEGIN:VEVENT');
|
||||
lines.push(property('UID', escapeText(event.uid)));
|
||||
lines.push(property('DTSTAMP', toUtcStamp(new Date())));
|
||||
|
||||
if (event.allDay) {
|
||||
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
|
||||
const exclusiveEnd = new Date((end ?? start).getTime() + 86_400_000);
|
||||
lines.push(`DTEND;VALUE=DATE:${toDateValue(exclusiveEnd)}`);
|
||||
} else {
|
||||
lines.push(property('DTSTART', toUtcStamp(start)));
|
||||
if (end && end.getTime() > start.getTime()) {
|
||||
lines.push(property('DTEND', toUtcStamp(end)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(property('SUMMARY', escapeText(event.summary)));
|
||||
if (event.description) {
|
||||
lines.push(property('DESCRIPTION', escapeText(event.description)));
|
||||
}
|
||||
if (event.location) {
|
||||
lines.push(property('LOCATION', escapeText(event.location)));
|
||||
}
|
||||
if (event.url && /^https?:\/\//i.test(event.url)) {
|
||||
lines.push(property('URL', event.url));
|
||||
}
|
||||
|
||||
lines.push(property('STATUS', event.status ?? 'CONFIRMED'));
|
||||
lines.push('END:VEVENT');
|
||||
}
|
||||
|
||||
lines.push('END:VCALENDAR');
|
||||
return `${lines.join(CRLF)}${CRLF}`;
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import { buildEventCalendar } from './event-calendar';
|
||||
import {
|
||||
readEmailBranding,
|
||||
resolveEmailTemplate,
|
||||
@@ -44,12 +45,15 @@ type Locale = 'en' | 'ar';
|
||||
type EventRow = {
|
||||
documentId: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
|
||||
startDateTime?: string | Date | null;
|
||||
endDateTime?: string | Date | null;
|
||||
timezone?: string | null;
|
||||
format?: 'in-person' | 'online' | 'hybrid' | null;
|
||||
venue?: string | null;
|
||||
onlineUrl?: string | null;
|
||||
region?: { name?: string | null } | null;
|
||||
};
|
||||
|
||||
/** An event looked up in both locales; missing locales are allowed. */
|
||||
@@ -65,6 +69,174 @@ const EMAIL_TAG = {
|
||||
|
||||
const DEFAULT_TIMEZONE = 'Africa/Tripoli';
|
||||
|
||||
type ReservationIdentity = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type MailAttachment = {
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
type JsonLd = Readonly<Record<string, unknown>>;
|
||||
|
||||
function validWebUrl(value: string | null | undefined): string | undefined {
|
||||
const candidate = value?.trim();
|
||||
if (!candidate) return undefined;
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
? url.toString()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isoDateTime(value: string | Date | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = typeof value === 'string' ? new Date(value) : value;
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
function eventLocationForJsonLd(event: BilingualEvent): JsonLd | undefined {
|
||||
const venue = event.en?.venue || event.ar?.venue;
|
||||
const region = event.en?.region?.name || event.ar?.region?.name;
|
||||
const format = event.en?.format || event.ar?.format;
|
||||
const onlineUrl = validWebUrl(event.en?.onlineUrl || event.ar?.onlineUrl);
|
||||
|
||||
if ((format === 'online' || (!venue && !region)) && onlineUrl) {
|
||||
return { '@type': 'VirtualLocation', url: onlineUrl };
|
||||
}
|
||||
|
||||
if (!venue && !region) return undefined;
|
||||
|
||||
return {
|
||||
'@type': 'Place',
|
||||
name: venue || region,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
...(region ? { addressLocality: region } : {}),
|
||||
addressCountry: 'LY',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEventReservationJsonLd(
|
||||
event: BilingualEvent,
|
||||
state: RegistrationState,
|
||||
reservation: ReservationIdentity,
|
||||
): JsonLd | undefined {
|
||||
const name = event.en?.title || event.ar?.title;
|
||||
const startDate = isoDateTime(
|
||||
event.en?.startDateTime ?? event.ar?.startDateTime,
|
||||
);
|
||||
const reservationNumber = reservation.id?.trim();
|
||||
const attendeeName = reservation.name?.trim();
|
||||
const location = eventLocationForJsonLd(event);
|
||||
|
||||
if (!name || !startDate || !reservationNumber || !attendeeName || !location) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const format = event.en?.format || event.ar?.format;
|
||||
const endDate = isoDateTime(event.en?.endDateTime ?? event.ar?.endDateTime);
|
||||
const description = event.en?.description || event.ar?.description;
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EventReservation',
|
||||
reservationNumber,
|
||||
reservationStatus:
|
||||
state === 'confirmed'
|
||||
? 'http://schema.org/Confirmed'
|
||||
: 'https://schema.org/ReservationPending',
|
||||
underName: { '@type': 'Person', name: attendeeName },
|
||||
reservationFor: {
|
||||
'@type': 'Event',
|
||||
name,
|
||||
startDate,
|
||||
...(endDate ? { endDate } : {}),
|
||||
...(description ? { description } : {}),
|
||||
location,
|
||||
eventAttendanceMode:
|
||||
format === 'online'
|
||||
? 'https://schema.org/OnlineEventAttendanceMode'
|
||||
: format === 'hybrid'
|
||||
? 'https://schema.org/MixedEventAttendanceMode'
|
||||
: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: 'https://schema.org/EventScheduled',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function eventCalendarFilename(title: string): string {
|
||||
const safeTitle = title
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f\u007f-\u009f]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^[. ]+|[. ]+$/g, '')
|
||||
.trim();
|
||||
const limitedTitle = Array.from(safeTitle).slice(0, 120).join('').replace(/[. ]+$/g, '');
|
||||
|
||||
return `${limitedTitle || 'event'}.ics`;
|
||||
}
|
||||
|
||||
function buildEventCalendarAttachment(
|
||||
event: BilingualEvent,
|
||||
state: RegistrationState,
|
||||
): MailAttachment | undefined {
|
||||
const eventId = event.en?.documentId || event.ar?.documentId;
|
||||
const start = event.en?.startDateTime ?? event.ar?.startDateTime;
|
||||
const summary = event.en?.title || event.ar?.title;
|
||||
if (!eventId || !start || !summary || !isoDateTime(start)) return undefined;
|
||||
|
||||
const onlineUrl = validWebUrl(event.en?.onlineUrl || event.ar?.onlineUrl);
|
||||
const descriptions = [
|
||||
event.en?.description || event.ar?.description,
|
||||
event.ar?.description && event.ar.description !== event.en?.description
|
||||
? event.ar.description
|
||||
: undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
const location =
|
||||
event.en?.venue ||
|
||||
event.ar?.venue ||
|
||||
event.en?.region?.name ||
|
||||
event.ar?.region?.name ||
|
||||
onlineUrl;
|
||||
|
||||
return {
|
||||
filename: eventCalendarFilename(summary),
|
||||
content: buildEventCalendar({
|
||||
uid: `${eventId}@sharper.ly`,
|
||||
start,
|
||||
end: event.en?.endDateTime ?? event.ar?.endDateTime,
|
||||
allDay: typeof start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(start),
|
||||
summary,
|
||||
...(descriptions.length > 0 ? { description: descriptions.join('\n\n') } : {}),
|
||||
...(location ? { location } : {}),
|
||||
...(onlineUrl ? { url: onlineUrl } : {}),
|
||||
status: state === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE',
|
||||
}),
|
||||
contentType: 'text/calendar; charset=utf-8; method=PUBLISH',
|
||||
};
|
||||
}
|
||||
|
||||
function renderJsonLd(jsonLd?: JsonLd): string {
|
||||
if (!jsonLd) return '';
|
||||
|
||||
const serialized = JSON.stringify(jsonLd);
|
||||
if (!serialized) return '';
|
||||
|
||||
const safeJson = serialized
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/&/g, '\\u0026');
|
||||
return `<script type="application/ld+json">${safeJson}</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the
|
||||
* latn numbering system, so digits never switch to Eastern Arabic numerals.
|
||||
@@ -217,8 +389,18 @@ export function buildBilingualMail(opts: {
|
||||
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
|
||||
locationLine?: { ar: string; en: string };
|
||||
branding?: EmailBranding;
|
||||
jsonLd?: JsonLd;
|
||||
}): { subject: string; text: string; html: string } {
|
||||
const { subjectAr, subjectEn, bodyAr, bodyEn, vars, locationLine, branding } = opts;
|
||||
const {
|
||||
subjectAr,
|
||||
subjectEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
vars,
|
||||
locationLine,
|
||||
branding,
|
||||
jsonLd,
|
||||
} = opts;
|
||||
|
||||
const varsAr = vars?.ar ?? {};
|
||||
const varsEn = vars?.en ?? {};
|
||||
@@ -229,6 +411,7 @@ export function buildBilingualMail(opts: {
|
||||
);
|
||||
|
||||
const html = `
|
||||
${renderJsonLd(jsonLd)}
|
||||
${renderBrandHeader(branding)}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
|
||||
<div dir="rtl" lang="ar">
|
||||
@@ -292,8 +475,16 @@ export async function fetchBilingualEvent(
|
||||
if (!filters) return {};
|
||||
|
||||
const [en, ar] = await Promise.all([
|
||||
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'en' }),
|
||||
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'ar' }),
|
||||
strapi.documents(EVENT_UID).findFirst({
|
||||
filters,
|
||||
locale: 'en',
|
||||
populate: { region: { fields: ['name'] } },
|
||||
}),
|
||||
strapi.documents(EVENT_UID).findFirst({
|
||||
filters,
|
||||
locale: 'ar',
|
||||
populate: { region: { fields: ['name'] } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { en: en ?? undefined, ar: ar ?? undefined };
|
||||
@@ -308,6 +499,7 @@ type MailOptions = {
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
attachments?: MailAttachment[];
|
||||
};
|
||||
|
||||
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
|
||||
@@ -359,11 +551,13 @@ export async function sendRegistrationConfirmation({
|
||||
to,
|
||||
state,
|
||||
eventReference,
|
||||
reservation,
|
||||
}: {
|
||||
strapi: Core.Strapi;
|
||||
to: string;
|
||||
state: RegistrationState;
|
||||
eventReference: EventReference;
|
||||
reservation: ReservationIdentity;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const isWaitlist = state === 'waitlisted';
|
||||
@@ -408,9 +602,15 @@ export async function sendRegistrationConfirmation({
|
||||
},
|
||||
locationLine: locationLineFor(event),
|
||||
branding,
|
||||
jsonLd: buildEventReservationJsonLd(event, state, reservation),
|
||||
});
|
||||
|
||||
return trySendMail(strapi, { to, ...mail });
|
||||
const attachment = buildEventCalendarAttachment(event, state);
|
||||
return trySendMail(strapi, {
|
||||
to,
|
||||
...mail,
|
||||
...(attachment ? { attachments: [attachment] } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
|
||||
return false;
|
||||
@@ -426,10 +626,12 @@ export async function sendEventReminderMail({
|
||||
strapi,
|
||||
to,
|
||||
eventReference,
|
||||
reservation,
|
||||
}: {
|
||||
strapi: Core.Strapi;
|
||||
to: string;
|
||||
eventReference: EventReference;
|
||||
reservation: ReservationIdentity;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const [event, template, branding] = await Promise.all([
|
||||
@@ -468,9 +670,15 @@ export async function sendEventReminderMail({
|
||||
},
|
||||
locationLine: locationLineFor(event),
|
||||
branding,
|
||||
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
|
||||
});
|
||||
|
||||
return trySendMail(strapi, { to, ...mail });
|
||||
const attachment = buildEventCalendarAttachment(event, 'confirmed');
|
||||
return trySendMail(strapi, {
|
||||
to,
|
||||
...mail,
|
||||
...(attachment ? { attachments: [attachment] } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
|
||||
return false;
|
||||
|
||||
@@ -126,6 +126,13 @@ export async function runEventReminders({
|
||||
strapi,
|
||||
to: registration.email as string,
|
||||
eventReference: reference,
|
||||
reservation: {
|
||||
id: registration.documentId as string,
|
||||
name:
|
||||
typeof registration.fullName === 'string'
|
||||
? registration.fullName
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
|
||||
@@ -110,6 +110,7 @@ async function promoteEvent(
|
||||
if (slots <= 0) break;
|
||||
|
||||
const email = (registration as { email?: unknown }).email;
|
||||
const fullName = (registration as { fullName?: unknown }).fullName;
|
||||
if (typeof email !== 'string' || email.length === 0) {
|
||||
strapi.log.warn(
|
||||
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
|
||||
@@ -122,6 +123,10 @@ async function promoteEvent(
|
||||
to: email,
|
||||
state: 'confirmed',
|
||||
eventReference: { documentId: eventDocumentId },
|
||||
reservation: {
|
||||
id: registration.documentId as string,
|
||||
name: typeof fullName === 'string' ? fullName : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
|
||||
Reference in New Issue
Block a user