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:
@@ -145,7 +145,9 @@ const resolveRegistrationState = async (
|
|||||||
const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId);
|
const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId);
|
||||||
|
|
||||||
const isFull =
|
const isFull =
|
||||||
event.registrationStatus === 'full' || (capacity !== null && taken >= capacity);
|
event.registrationStatus === 'full' ||
|
||||||
|
event.registrationStatus === 'waitlist' ||
|
||||||
|
(capacity !== null && taken >= capacity);
|
||||||
|
|
||||||
if (!isFull) {
|
if (!isFull) {
|
||||||
return 'confirmed';
|
return 'confirmed';
|
||||||
@@ -206,11 +208,30 @@ const sanitizeSubmission: Core.MiddlewareFactory =
|
|||||||
// ("Transaction query already complete"). Dispatched without awaiting so a
|
// ("Transaction query already complete"). Dispatched without awaiting so a
|
||||||
// slow SMTP call can never turn the registration POST into a 502.
|
// slow SMTP call can never turn the registration POST into a 502.
|
||||||
if (typeof data.email === 'string' && data.email.length > 0) {
|
if (typeof data.email === 'string' && data.email.length > 0) {
|
||||||
|
const response = ctx.body as
|
||||||
|
| {
|
||||||
|
data?: { documentId?: unknown; id?: unknown };
|
||||||
|
documentId?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const created = response?.data ?? response;
|
||||||
|
const reservationId =
|
||||||
|
typeof created?.documentId === 'string'
|
||||||
|
? created.documentId
|
||||||
|
: typeof created?.id === 'number'
|
||||||
|
? String(created.id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
void sendRegistrationConfirmation({
|
void sendRegistrationConfirmation({
|
||||||
strapi,
|
strapi,
|
||||||
to: data.email,
|
to: data.email,
|
||||||
state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
|
state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
|
||||||
eventReference: readEventReference(data.event),
|
eventReference: readEventReference(data.event),
|
||||||
|
reservation: {
|
||||||
|
id: reservationId,
|
||||||
|
name: typeof data.fullName === 'string' ? data.fullName : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
strapi.log.warn(
|
strapi.log.warn(
|
||||||
|
|||||||
@@ -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 type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
|
import { buildEventCalendar } from './event-calendar';
|
||||||
import {
|
import {
|
||||||
readEmailBranding,
|
readEmailBranding,
|
||||||
resolveEmailTemplate,
|
resolveEmailTemplate,
|
||||||
@@ -44,12 +45,15 @@ type Locale = 'en' | 'ar';
|
|||||||
type EventRow = {
|
type EventRow = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
|
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
|
||||||
startDateTime?: string | Date | null;
|
startDateTime?: string | Date | null;
|
||||||
|
endDateTime?: string | Date | null;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
format?: 'in-person' | 'online' | 'hybrid' | null;
|
format?: 'in-person' | 'online' | 'hybrid' | null;
|
||||||
venue?: string | null;
|
venue?: string | null;
|
||||||
onlineUrl?: string | null;
|
onlineUrl?: string | null;
|
||||||
|
region?: { name?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** An event looked up in both locales; missing locales are allowed. */
|
/** An event looked up in both locales; missing locales are allowed. */
|
||||||
@@ -65,6 +69,174 @@ const EMAIL_TAG = {
|
|||||||
|
|
||||||
const DEFAULT_TIMEZONE = 'Africa/Tripoli';
|
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
|
* Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the
|
||||||
* latn numbering system, so digits never switch to Eastern Arabic numerals.
|
* latn numbering system, so digits never switch to Eastern Arabic numerals.
|
||||||
@@ -217,8 +389,18 @@ export function buildBilingualMail(opts: {
|
|||||||
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
|
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
|
||||||
locationLine?: { ar: string; en: string };
|
locationLine?: { ar: string; en: string };
|
||||||
branding?: EmailBranding;
|
branding?: EmailBranding;
|
||||||
|
jsonLd?: JsonLd;
|
||||||
}): { subject: string; text: string; html: string } {
|
}): { 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 varsAr = vars?.ar ?? {};
|
||||||
const varsEn = vars?.en ?? {};
|
const varsEn = vars?.en ?? {};
|
||||||
@@ -229,6 +411,7 @@ export function buildBilingualMail(opts: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
|
${renderJsonLd(jsonLd)}
|
||||||
${renderBrandHeader(branding)}
|
${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;">
|
<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">
|
<div dir="rtl" lang="ar">
|
||||||
@@ -292,8 +475,16 @@ export async function fetchBilingualEvent(
|
|||||||
if (!filters) return {};
|
if (!filters) return {};
|
||||||
|
|
||||||
const [en, ar] = await Promise.all([
|
const [en, ar] = await Promise.all([
|
||||||
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'en' }),
|
strapi.documents(EVENT_UID).findFirst({
|
||||||
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'ar' }),
|
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 };
|
return { en: en ?? undefined, ar: ar ?? undefined };
|
||||||
@@ -308,6 +499,7 @@ type MailOptions = {
|
|||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
|
attachments?: MailAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
|
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
|
||||||
@@ -359,11 +551,13 @@ export async function sendRegistrationConfirmation({
|
|||||||
to,
|
to,
|
||||||
state,
|
state,
|
||||||
eventReference,
|
eventReference,
|
||||||
|
reservation,
|
||||||
}: {
|
}: {
|
||||||
strapi: Core.Strapi;
|
strapi: Core.Strapi;
|
||||||
to: string;
|
to: string;
|
||||||
state: RegistrationState;
|
state: RegistrationState;
|
||||||
eventReference: EventReference;
|
eventReference: EventReference;
|
||||||
|
reservation: ReservationIdentity;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const isWaitlist = state === 'waitlisted';
|
const isWaitlist = state === 'waitlisted';
|
||||||
@@ -408,9 +602,15 @@ export async function sendRegistrationConfirmation({
|
|||||||
},
|
},
|
||||||
locationLine: locationLineFor(event),
|
locationLine: locationLineFor(event),
|
||||||
branding,
|
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) {
|
} catch (error) {
|
||||||
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
|
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -426,10 +626,12 @@ export async function sendEventReminderMail({
|
|||||||
strapi,
|
strapi,
|
||||||
to,
|
to,
|
||||||
eventReference,
|
eventReference,
|
||||||
|
reservation,
|
||||||
}: {
|
}: {
|
||||||
strapi: Core.Strapi;
|
strapi: Core.Strapi;
|
||||||
to: string;
|
to: string;
|
||||||
eventReference: EventReference;
|
eventReference: EventReference;
|
||||||
|
reservation: ReservationIdentity;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const [event, template, branding] = await Promise.all([
|
const [event, template, branding] = await Promise.all([
|
||||||
@@ -468,9 +670,15 @@ export async function sendEventReminderMail({
|
|||||||
},
|
},
|
||||||
locationLine: locationLineFor(event),
|
locationLine: locationLineFor(event),
|
||||||
branding,
|
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) {
|
} catch (error) {
|
||||||
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
|
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ export async function runEventReminders({
|
|||||||
strapi,
|
strapi,
|
||||||
to: registration.email as string,
|
to: registration.email as string,
|
||||||
eventReference: reference,
|
eventReference: reference,
|
||||||
|
reservation: {
|
||||||
|
id: registration.documentId as string,
|
||||||
|
name:
|
||||||
|
typeof registration.fullName === 'string'
|
||||||
|
? registration.fullName
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ async function promoteEvent(
|
|||||||
if (slots <= 0) break;
|
if (slots <= 0) break;
|
||||||
|
|
||||||
const email = (registration as { email?: unknown }).email;
|
const email = (registration as { email?: unknown }).email;
|
||||||
|
const fullName = (registration as { fullName?: unknown }).fullName;
|
||||||
if (typeof email !== 'string' || email.length === 0) {
|
if (typeof email !== 'string' || email.length === 0) {
|
||||||
strapi.log.warn(
|
strapi.log.warn(
|
||||||
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
|
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
|
||||||
@@ -122,6 +123,10 @@ async function promoteEvent(
|
|||||||
to: email,
|
to: email,
|
||||||
state: 'confirmed',
|
state: 'confirmed',
|
||||||
eventReference: { documentId: eventDocumentId },
|
eventReference: { documentId: eventDocumentId },
|
||||||
|
reservation: {
|
||||||
|
id: registration.documentId as string,
|
||||||
|
name: typeof fullName === 'string' ? fullName : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
useTranslation,
|
useTranslation,
|
||||||
type Locale,
|
type Locale,
|
||||||
} from "~/i18n";
|
} from "~/i18n";
|
||||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
|
||||||
|
|
||||||
import type { Route } from "./+types/events-calendar";
|
import type { Route } from "./+types/events-calendar";
|
||||||
import {
|
import {
|
||||||
@@ -185,10 +185,25 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ location, matches }: Route.MetaArgs) {
|
export function meta({ location, matches, loaderData }: Route.MetaArgs) {
|
||||||
const root = readRootSeo(matches);
|
const root = readRootSeo(matches);
|
||||||
const copy = getRouteCopy(root.locale);
|
const copy = getRouteCopy(root.locale);
|
||||||
const search = new URLSearchParams(location.search);
|
const search = new URLSearchParams(location.search);
|
||||||
|
const eventJsonLd = (loaderData?.events ?? []).map((event) =>
|
||||||
|
buildEventJsonLd({
|
||||||
|
siteUrl: root.siteUrl,
|
||||||
|
locale: root.locale,
|
||||||
|
name: event.title,
|
||||||
|
pathname: `${sectionPath("events", root.locale)}/${encodeURIComponent(event.slug)}`,
|
||||||
|
startDate: toIsoDate(event.start) ?? event.start,
|
||||||
|
...(event.end ? { endDate: toIsoDate(event.end) ?? event.end } : {}),
|
||||||
|
...(event.summary ? { description: event.summary } : {}),
|
||||||
|
...(event.image ? { image: event.image.src } : {}),
|
||||||
|
...(event.place ? { locationName: event.place } : {}),
|
||||||
|
...(event.region ? { addressLocality: event.region } : {}),
|
||||||
|
isOnline: event.format === "online" || event.format === "hybrid",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return buildMeta({
|
return buildMeta({
|
||||||
locale: root.locale,
|
locale: root.locale,
|
||||||
@@ -204,16 +219,19 @@ export function meta({ location, matches }: Route.MetaArgs) {
|
|||||||
search.has("page") ||
|
search.has("page") ||
|
||||||
search.has("view") ||
|
search.has("view") ||
|
||||||
search.has("month"),
|
search.has("month"),
|
||||||
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
jsonLd: [
|
||||||
{
|
buildBreadcrumbJsonLd(root.siteUrl, [
|
||||||
name: translate(root.locale, "nav.sections.home"),
|
{
|
||||||
pathname: localizedPath("/", root.locale),
|
name: translate(root.locale, "nav.sections.home"),
|
||||||
},
|
pathname: localizedPath("/", root.locale),
|
||||||
{
|
},
|
||||||
name: translate(root.locale, "nav.sections.events"),
|
{
|
||||||
pathname: sectionPath("events", root.locale),
|
name: translate(root.locale, "nav.sections.events"),
|
||||||
},
|
pathname: sectionPath("events", root.locale),
|
||||||
]),
|
},
|
||||||
|
]),
|
||||||
|
...eventJsonLd,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user