diff --git a/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts b/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts index f1cef43..980f91d 100644 --- a/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts +++ b/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts @@ -145,7 +145,9 @@ const resolveRegistrationState = async ( const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId); const isFull = - event.registrationStatus === 'full' || (capacity !== null && taken >= capacity); + event.registrationStatus === 'full' || + event.registrationStatus === 'waitlist' || + (capacity !== null && taken >= capacity); if (!isFull) { return 'confirmed'; @@ -206,11 +208,30 @@ const sanitizeSubmission: Core.MiddlewareFactory = // ("Transaction query already complete"). Dispatched without awaiting so a // slow SMTP call can never turn the registration POST into a 502. 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({ strapi, to: data.email, state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed', eventReference: readEventReference(data.event), + reservation: { + id: reservationId, + name: typeof data.fullName === 'string' ? data.fullName : undefined, + }, }); } else { strapi.log.warn( diff --git a/apps/cms/src/lib/event-calendar.ts b/apps/cms/src/lib/event-calendar.ts new file mode 100644 index 0000000..a02fc0b --- /dev/null +++ b/apps/cms/src/lib/event-calendar.ts @@ -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}`; +} \ No newline at end of file diff --git a/apps/cms/src/lib/event-mail.ts b/apps/cms/src/lib/event-mail.ts index 08d34ae..6025b20 100644 --- a/apps/cms/src/lib/event-mail.ts +++ b/apps/cms/src/lib/event-mail.ts @@ -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>; + +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, '\\u003e') + .replace(/&/g, '\\u0026'); + return ``; +} + /** * 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)}
@@ -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 }; @@ -359,11 +551,13 @@ export async function sendRegistrationConfirmation({ to, state, eventReference, + reservation, }: { strapi: Core.Strapi; to: string; state: RegistrationState; eventReference: EventReference; + reservation: ReservationIdentity; }): Promise { 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 { 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; diff --git a/apps/cms/src/lib/event-reminders.ts b/apps/cms/src/lib/event-reminders.ts index 1faa98d..9a2cb81 100644 --- a/apps/cms/src/lib/event-reminders.ts +++ b/apps/cms/src/lib/event-reminders.ts @@ -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) { diff --git a/apps/cms/src/lib/waitlist-promotion.ts b/apps/cms/src/lib/waitlist-promotion.ts index 3b66df4..c4e115f 100644 --- a/apps/cms/src/lib/waitlist-promotion.ts +++ b/apps/cms/src/lib/waitlist-promotion.ts @@ -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) { diff --git a/apps/web/app/routes/events-calendar.tsx b/apps/web/app/routes/events-calendar.tsx index a0dfbc1..c5a17f6 100644 --- a/apps/web/app/routes/events-calendar.tsx +++ b/apps/web/app/routes/events-calendar.tsx @@ -50,7 +50,7 @@ import { useTranslation, type Locale, } from "~/i18n"; -import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; +import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo"; import type { Route } from "./+types/events-calendar"; 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 copy = getRouteCopy(root.locale); 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({ locale: root.locale, @@ -204,16 +219,19 @@ export function meta({ location, matches }: Route.MetaArgs) { search.has("page") || search.has("view") || search.has("month"), - jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [ - { - name: translate(root.locale, "nav.sections.home"), - pathname: localizedPath("/", root.locale), - }, - { - name: translate(root.locale, "nav.sections.events"), - pathname: sectionPath("events", root.locale), - }, - ]), + jsonLd: [ + buildBreadcrumbJsonLd(root.siteUrl, [ + { + name: translate(root.locale, "nav.sections.home"), + pathname: localizedPath("/", root.locale), + }, + { + name: translate(root.locale, "nav.sections.events"), + pathname: sectionPath("events", root.locale), + }, + ]), + ...eventJsonLd, + ], }); }