From 0bdbc0022aaa6a29ae6db05624781e2a47ed26c0 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:21:57 +0100 Subject: [PATCH 01/10] Automatically invite guests when event Cards are created --- backend/src/calendar/holdOverlay.ts | 2 +- backend/src/calendar/holds.ts | 4 +- backend/src/calendar/linkConfirmedHold.ts | 2 +- backend/src/cards/createCard.ts | 64 +++-- backend/src/db/sponsorDocs.ts | 16 +- backend/src/db/templates.ts | 7 + backend/src/events/guestContact.ts | 80 ++++++ backend/src/events/guestFirstContact.ts | 39 +++ .../src/events/guestFirstContactDelivery.ts | 39 +++ backend/src/events/guestIntakeAllocator.ts | 15 ++ backend/src/events/guestIntakeCompletion.ts | 15 ++ backend/src/events/guestIntakeCreate.ts | 80 ++++++ backend/src/events/guestIntakePolicy.ts | 28 +++ backend/src/events/guestIntakePortal.ts | 150 +++++++++++ backend/src/events/guestIntakeSlots.ts | 57 +++++ backend/src/events/guestIntakeStore.ts | 55 ++++ backend/src/events/guestIntakeWorker.ts | 134 ++++++++++ backend/src/events/lifecycle.ts | 15 +- backend/src/events/policy.ts | 4 +- backend/src/events/service.ts | 36 ++- backend/src/handler.ts | 18 ++ backend/src/podcast/firstContact.ts | 141 +---------- backend/src/routes/calendarHolds.ts | 5 +- backend/src/routes/cards.ts | 42 ++++ backend/src/routes/podcastFirstContact.ts | 20 +- backend/src/routes/sponsorDocs.ts | 12 +- backend/src/sponsorDocs/eventMetadata.ts | 13 + backend/src/sponsorDocs/templates.ts | 75 +++++- backend/src/templates/authoredTemplates.ts | 1 + .../src/templates/cardTemplateProjection.ts | 1 + backend/src/templates/cardTemplateUpdates.ts | 2 + backend/src/templates/templateEdits.ts | 1 + backend/src/templates/yamlTemplates.ts | 3 + backend/src/types.ts | 3 + backend/tests/calendar-holds.test.ts | 8 +- backend/tests/event-guest-contact.test.ts | 49 ++++ backend/tests/event-service.test.ts | 6 +- backend/tests/guest-event-form.test.ts | 27 ++ backend/tests/guest-intake-allocation.test.ts | 57 +++++ backend/tests/guest-intake-worker.test.ts | 234 ++++++++++++++++++ backend/tests/helpers/guest-intake-google.ts | 127 ++++++++++ backend/tests/podcast-first-contact.test.ts | 171 +++++-------- backend/tests/podcast-guest-form.test.ts | 2 +- .../tests/sponsor-doc-event-metadata.test.ts | 22 +- cli/src/commands/calendar.mjs | 1 + cli/src/commands/cards.mjs | 85 ++++++- cli/src/commands/podcast.mjs | 16 +- cli/test/calendar.test.mjs | 26 +- cli/test/cards-invitation.test.mjs | 39 +++ cli/test/podcast.test.mjs | 5 +- cli/test/work.test.mjs | 58 +++++ .../operations/sponsor-doc-event-time.js | 36 +++ .../surfaces/operations/sponsor-doc-radio.js | 1 + .../src/surfaces/operations/sponsor-docs.js | 55 +++- frontend/src/surfaces/tasks/quick-create.js | 158 +++++++++++- .../src/surfaces/work-detail/card-panel.js | 3 + .../src/surfaces/work-detail/card-summary.js | 107 ++++++++ frontend/test/guest-invitation.test.mjs | 68 +++++ frontend/test/sponsor-doc-event-time.test.mjs | 18 ++ frontend/test/sponsor-docs.test.mjs | 37 +++ frontend/test/tasks-surface.test.mjs | 164 +++++++++++- infra/template.full.yaml | 59 +++++ 62 files changed, 2459 insertions(+), 359 deletions(-) create mode 100644 backend/src/events/guestContact.ts create mode 100644 backend/src/events/guestFirstContact.ts create mode 100644 backend/src/events/guestFirstContactDelivery.ts create mode 100644 backend/src/events/guestIntakeAllocator.ts create mode 100644 backend/src/events/guestIntakeCompletion.ts create mode 100644 backend/src/events/guestIntakeCreate.ts create mode 100644 backend/src/events/guestIntakePolicy.ts create mode 100644 backend/src/events/guestIntakePortal.ts create mode 100644 backend/src/events/guestIntakeSlots.ts create mode 100644 backend/src/events/guestIntakeStore.ts create mode 100644 backend/src/events/guestIntakeWorker.ts create mode 100644 backend/tests/event-guest-contact.test.ts create mode 100644 backend/tests/guest-event-form.test.ts create mode 100644 backend/tests/guest-intake-allocation.test.ts create mode 100644 backend/tests/guest-intake-worker.test.ts create mode 100644 backend/tests/helpers/guest-intake-google.ts create mode 100644 cli/test/cards-invitation.test.mjs create mode 100644 frontend/src/surfaces/operations/sponsor-doc-event-time.js create mode 100644 frontend/test/guest-invitation.test.mjs create mode 100644 frontend/test/sponsor-doc-event-time.test.mjs diff --git a/backend/src/calendar/holdOverlay.ts b/backend/src/calendar/holdOverlay.ts index 7b4854207..4a7aaf835 100644 --- a/backend/src/calendar/holdOverlay.ts +++ b/backend/src/calendar/holdOverlay.ts @@ -27,7 +27,7 @@ export function calendarHoldOverlay(holds: CalendarHold[]): CalendarOverlayProvi id: `calendar-hold:${hold.id}`, startDate: hold.date, endDate: hold.date, - label: `${hold.status === 'tentative' ? '(Proposal) ' : ''}${hold.title} · ${time}`, + label: `${hold.status === 'tentative' ? '[Proposed] ' : ''}${hold.title} · ${time}`, category: 'Calendar hold', slot: hold.window, isSlotEvent: true, diff --git a/backend/src/calendar/holds.ts b/backend/src/calendar/holds.ts index b7972930c..0ae0c0c15 100644 --- a/backend/src/calendar/holds.ts +++ b/backend/src/calendar/holds.ts @@ -19,7 +19,7 @@ export type CalendarHoldStatus = 'tentative' | 'confirmed' | 'released' | 'expir export type CalendarHoldReleaseReason = 'confirmed' | 'expired' | 'manual'; export type CalendarHoldWindow = 'midday' | 'evening'; -export const HOLD_PROPOSAL_PREFIX = '(Proposal) '; +export const HOLD_PROPOSAL_PREFIX = '[Proposed] '; export const HOLD_DEFAULT_DURATION_MINUTES = 60; export const HOLD_DEFAULT_WINDOW: CalendarHoldWindow = 'evening'; export const HOLD_DEFAULT_TTL_DAYS = 7; @@ -224,7 +224,7 @@ export function proposalTitle(title: string): string { } export function confirmedTitle(title: string): string { - return title.replace(/^(?:(?:\(Proposal\)|Proposed\s*[-–—:]?)\s*)+/i, '').trim(); + return title.replace(/^(?:(?:\(Proposal\)|\[Proposed\]|Proposed\s*[-–—:]?)\s*)+/i, '').trim(); } /** SOP podcast convention: the confirmed invite is named for the guest, not the topic. */ diff --git a/backend/src/calendar/linkConfirmedHold.ts b/backend/src/calendar/linkConfirmedHold.ts index cac4ef1b4..bfed00d06 100644 --- a/backend/src/calendar/linkConfirmedHold.ts +++ b/backend/src/calendar/linkConfirmedHold.ts @@ -66,7 +66,7 @@ export async function linkConfirmedHold( if (event.organizer?.email?.toLowerCase() !== HOLD_ORGANIZER || event.location !== HOLD_ZOOM_LOCATION || !expectedInvite.attendees.every(email => event.attendees?.some(a => a.email?.toLowerCase() === email)) || guests.some(email => event.attendees?.some(a => a.email?.toLowerCase() === email && a.responseStatus === 'declined')) - || /^\(Proposal\)|^Proposed -/i.test(event.summary || '') + || /^\(Proposal\)|^\[Proposed\]|^Proposed -/i.test(event.summary || '') || event.attendees?.some(a => HOLD_ATTENDEE_DENY_LIST.includes(a.email?.toLowerCase() || '')) || event.attendees?.some(a => a.email === PUBLIC_DTC_EVENTS_CALENDAR)) throw new HoldLinkError('Google invitation organizer, guests or Zoom do not match the DataOps invitation'); } diff --git a/backend/src/cards/createCard.ts b/backend/src/cards/createCard.ts index 7050e6ed8..869f60596 100644 --- a/backend/src/cards/createCard.ts +++ b/backend/src/cards/createCard.ts @@ -8,6 +8,9 @@ */ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { prepareGuestIntake } from '../events/guestIntakeCreate'; +import { berlinToday } from '../calendar/ownerCalendarOverlay'; +import { GuestContactInputError } from '../events/guestContact'; import { normalizeCardLinks } from '../artifactLinks'; import { createCardFromTemplate, @@ -168,25 +171,10 @@ export async function createCardFromInput( if (body.title !== undefined && typeof body.title !== 'string') { return fail(400, { error: 'title must be a string' }); } - const undated = options.provisionalAnchorDate !== undefined; - if (undated && body.anchorDate !== undefined) { - return fail(400, { error: 'An undated Card takes no anchorDate' }); - } - const anchorDate = undated ? options.provisionalAnchorDate : body.anchorDate; - if (!anchorDate) { - return fail(400, { error: 'Missing required field: anchorDate' }); - } - if (!/^\d{4}-\d{2}-\d{2}$/.test(anchorDate as string)) { - return fail(400, { error: 'Invalid anchorDate format, expected YYYY-MM-DD' }); - } - if (body.endDate !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(body.endDate as string)) { - return fail(400, { error: 'Invalid endDate format, expected YYYY-MM-DD' }); - } - const systemOwnedFields = [ 'version', 'expectedVersion', 'status', 'taskCount', 'openTaskCount', 'completedAt', 'completedBy', 'activeStageBeforeCompletion', 'auditEventRefs', 'selectedOptionalGroups', - 'linkedFrom', + 'linkedFrom', 'guestIntake', ]; const suppliedSystemField = systemOwnedFields.find((field) => Object.hasOwn(body, field)); if (suppliedSystemField) { @@ -200,12 +188,38 @@ export async function createCardFromInput( const template = await getTemplate(client, body.templateId); if (!template) return fail(404, { error: 'Template not found' }); + let intake: Awaited>; + try { + intake = await prepareGuestIntake(client, actor.id, template, body); + } catch (error) { + return fail(error instanceof GuestContactInputError ? error.statusCode : 400, { error: (error as Error).message }); + } + if (intake) { + const existing = await intake.replay(); + if (existing) return existing; + } + const undated = options.provisionalAnchorDate !== undefined || Boolean(intake && body.anchorDate === undefined); + if (undated && body.anchorDate !== undefined) { + return fail(400, { error: 'An undated Card takes no anchorDate' }); + } + const anchorDate = undated ? (options.provisionalAnchorDate || berlinToday()) : body.anchorDate; + if (!anchorDate) { + return fail(400, { error: 'Missing required field: anchorDate' }); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(anchorDate as string)) { + return fail(400, { error: 'Invalid anchorDate format, expected YYYY-MM-DD' }); + } + if (body.endDate !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(body.endDate as string)) { + return fail(400, { error: 'Invalid endDate format, expected YYYY-MM-DD' }); + } + // A Template with a card_title pattern names its Cards; otherwise the // caller must. let title = typeof body.title === 'string' ? body.title.trim() : ''; if (!title && template.cardTitle) { title = renderCardTitle(template.cardTitle, cardFieldTitleValues(template.type || '', body)) || template.name; } + if (!title && intake) title = intake.title; if (!title) return fail(400, { error: 'Missing required field: title' }); const VALID_STAGES = ['preparation', 'announced', 'after-event']; @@ -279,6 +293,12 @@ export async function createCardFromInput( } } + if (intake) { + cardData.guestIntake = intake.marker; + if (intake.job.guestName && !cardData.people) cardData.people = [intake.job.guestName]; + if (intake.job.guestEmail) cardData.guestEmails = [intake.job.guestEmail]; + } + const parsed = parseCardTitle(String(cardData.title || '')); if (parsed.tag && parsed.templateType) { if (cardData.issueNumber === undefined && parsed.issueNumber) { @@ -337,11 +357,21 @@ export async function createCardFromInput( template, anchorDate as string, selectedGroups, - options.companions, + (card) => [...(options.companions?.(card) || []), ...(intake?.companions(card) || [])], body.counts as Record | undefined, + intake?.tasks, ); return { ok: true, card, tasks, template }; } catch (error) { + if (intake) { + try { + const existing = await intake.replay(); + if (existing) return existing; + } catch (replayError) { + if (replayError instanceof GuestContactInputError) return fail(replayError.statusCode, { error: replayError.message, code: replayError.code }); + throw replayError; + } + } if (error instanceof TemplateVersionConflictError) { return fail(409, { code: 'template_version_conflict', diff --git a/backend/src/db/sponsorDocs.ts b/backend/src/db/sponsorDocs.ts index 2fde2ce89..c694d7ac3 100644 --- a/backend/src/db/sponsorDocs.ts +++ b/backend/src/db/sponsorDocs.ts @@ -194,6 +194,8 @@ function cleanSponsorDoc(item: Record | undefined): { } export interface CreateSponsorDocInput { + /** Internal durable orchestration identity. */ + id?: string; cardId: string; template: SponsorDocTemplateKey; title: string; @@ -209,7 +211,17 @@ export async function createSponsorDoc( client: DynamoDBDocumentClient, input: CreateSponsorDocInput, ): Promise<{ doc: SponsorDoc; accessCode: string }> { - const id = `sdoc-${randomBytes(8).toString('hex')}`; + const id = input.id || `sdoc-${randomBytes(8).toString('hex')}`; + if (input.id) { + const prior = await getSponsorDocWithAuthMaterial(client, id); + if (prior) { + if (prior.doc.cardId !== input.cardId || prior.doc.template !== input.template) throw new Error('Document identity belongs to another Card or type'); + const accessCode = decryptSponsorDocAccessCode(id, prior.accessCodeCiphertext); + if (!accessCode) throw new Error('Saved guest document access code is unavailable'); + await client.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: { ...cardSponsorDocIndexKey(prior.doc.cardId, id), ...stripDerivedDocumentFields(toSummary(prior.doc)) } })); + return { doc: prior.doc, accessCode }; + } + } const now = new Date().toISOString(); const generated = generateSponsorDocAccessCode(); const ciphertext = encryptAccessCode(id, generated.code); @@ -251,7 +263,7 @@ export async function createSponsorDoc( ...stripDerivedDocumentFields(toSummary(doc)), }; - await client.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: primaryItem })); + await client.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: primaryItem, ...(input.id ? { ConditionExpression: 'attribute_not_exists(PK)' } : {}) })); await client.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: indexItem })); return { doc, accessCode: generated.code }; diff --git a/backend/src/db/templates.ts b/backend/src/db/templates.ts index c56d40cbd..933c67a8e 100644 --- a/backend/src/db/templates.ts +++ b/backend/src/db/templates.ts @@ -917,6 +917,7 @@ async function createCardFromTemplate( selectedGroups?: string[], companions: (card: Card) => PlannedWrite[] = () => [], counts?: Record, + extraTasks?: (card: Card) => Task[], ): Promise<{ card: Card; tasks: Task[] }> { const resolved = await resolveTemplateConditionalValidation(client, template, anchorDate); const cardCounts = cardCountValues(resolved, counts); @@ -940,6 +941,12 @@ async function createCardFromTemplate( updatedAt: now, ...templateTaskProjection(resolved, definition, definition.order!, anchorDate, card.id, assignment, idsByRef, endDate), }) as Task); + if (extraTasks) { + const additional = extraTasks(card); + tasks.push(...additional); + card.taskCount += additional.length; + card.openTaskCount += additional.filter(t => t.status === 'todo' || t.status === 'waiting').length; + } const companionWrites = companions(card); // Card Put + Template fenced Update + one Put per Task + the owner's writes. diff --git a/backend/src/events/guestContact.ts b/backend/src/events/guestContact.ts new file mode 100644 index 000000000..241473ed2 --- /dev/null +++ b/backend/src/events/guestContact.ts @@ -0,0 +1,80 @@ +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getCrmRecord } from '../db/sponsorCrm'; + +export interface GuestContactInput { + guestEmail?: unknown; + guestName?: unknown; + guestPersonId?: unknown; + guestContactId?: unknown; + guestReference?: unknown; +} + +export interface ResolvedGuestContact { + email?: string; + name?: string; + reference?: { kind: 'contact'; id: string }; +} + +export class GuestContactInputError extends Error { + constructor(message: string, readonly statusCode = 400, readonly code = 'invalid_guest_contact') { + super(message); + this.name = 'GuestContactInputError'; + } +} + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const blocked = (record: Record) => Boolean(record.doNotContact + || (Array.isArray(record.tags) && record.tags.includes('do-not-contact'))); + +/** CRM People are CONTACT projections. Public website speaker profiles have no private email. */ +export async function resolveGuestContact( + client: DynamoDBDocumentClient, + input: GuestContactInput, +): Promise { + let email: string | undefined; + if (input.guestEmail !== undefined && input.guestEmail !== '') { + if (typeof input.guestEmail !== 'string' || input.guestEmail.trim().length > 254 || !EMAIL.test(input.guestEmail.trim())) { + throw new GuestContactInputError('guestEmail must be a valid email address'); + } + email = input.guestEmail.trim().toLowerCase(); + } + let name: string | undefined; + if (input.guestName !== undefined && input.guestName !== '') { + if (typeof input.guestName !== 'string' || input.guestName.length > 200 || /[\r\n]/.test(input.guestName)) { + throw new GuestContactInputError('guestName must be a single-line name of at most 200 characters'); + } + name = input.guestName.trim() || undefined; + } + const references = [input.guestPersonId, input.guestContactId].filter(value => value !== undefined && value !== ''); + if (input.guestReference !== undefined) { + const ref = input.guestReference; + if (!ref || typeof ref !== 'object' || Array.isArray(ref) + || !['person', 'contact'].includes(String((ref as Record).kind))) { + throw new GuestContactInputError('guestReference must identify a saved CRM person or contact'); + } + references.push((ref as Record).id); + } + if (!references.length) return { ...(email ? { email } : {}), ...(name ? { name } : {}) }; + if (references.some(value => typeof value !== 'string' || !ID.test(value.trim()))) { + throw new GuestContactInputError('The saved guest reference must be a valid CRM id'); + } + const ids = [...new Set((references as string[]).map(value => value.trim()))]; + if (ids.length !== 1) throw new GuestContactInputError('Guest references identify different saved contacts'); + const contact = await getCrmRecord(client, 'contact', ids[0]); + if (!contact) throw new GuestContactInputError('Saved guest contact not found', 404, 'guest_contact_not_found'); + if (contact.archivedAt || contact.active === false) { + throw new GuestContactInputError('The saved guest contact is inactive', 422, 'guest_contact_inactive'); + } + const company = typeof contact.organizationId === 'string' + ? await getCrmRecord(client, 'organization', contact.organizationId) : null; + if (blocked(contact) || (company && blocked(company))) { + throw new GuestContactInputError('The saved guest is marked do not contact', 422, 'guest_contact_blocked'); + } + const savedEmail = Array.isArray(contact.emails) + ? contact.emails.find(value => typeof value === 'string' && value.trim().length <= 254 && EMAIL.test(value.trim())) : undefined; + // Only the structured email list is a source. Notes, social links and plain Card.people names are not. + email ||= typeof savedEmail === 'string' ? savedEmail.trim().toLowerCase() : undefined; + name ||= typeof contact.name === 'string' ? contact.name.trim() || undefined : undefined; + return { ...(email ? { email } : {}), ...(name ? { name } : {}), reference: { kind: 'contact', id: ids[0] } }; +} diff --git a/backend/src/events/guestFirstContact.ts b/backend/src/events/guestFirstContact.ts new file mode 100644 index 000000000..5268231cf --- /dev/null +++ b/backend/src/events/guestFirstContact.ts @@ -0,0 +1,39 @@ +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getTextTemplatesRecord } from '../db/textTemplates'; +import { renderTextTemplate } from '../textTemplates/render'; +import { PODCAST_FIRST_CONTACT_FROM, formatSlotLabel } from '../podcast/firstContact'; +import { APPROVED_GUEST_EVENT_TYPES } from './guestIntakePolicy'; + +export interface GuestFirstContactInput { + type: string; templateId: string; guestName?: string; topic?: string; + startIso: string; guestTimezone?: string; formUrl: string; accessCode: string; +} +export function guestFirstContactValues(input: GuestFirstContactInput): Record { + if (!APPROVED_GUEST_EVENT_TYPES.has(input.type)) throw new Error('This event type has no enabled guest invitation'); + const firstName = input.guestName?.trim().split(/\s+/)[0] || ''; + const topic = input.topic?.trim() || ''; + for (const value of [firstName, topic, input.accessCode]) if (/[\r\n]/.test(value)) throw new Error('Invitation values must be single-line'); + if (!input.accessCode || !/^https:\/\//.test(input.formUrl)) throw new Error('A guest form URL and access code are required'); + const instant = new Date(input.startIso); + if (!Number.isFinite(instant.getTime())) throw new Error('A saved proposal instant is required'); + const date = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Berlin', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(instant).replace(',', ''); + let time = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).format(instant); + if (input.guestTimezone && input.guestTimezone !== 'Europe/Berlin') time += ` (${formatSlotLabel(input.startIso, input.guestTimezone)} in ${input.guestTimezone})`; + return { GREETING: firstName ? `Hi ${firstName},` : 'Hi,', FIRST_NAME_SUBJECT_PREFIX: firstName ? `${firstName} at ` : '', + TOPIC_CLAUSE: topic ? `${input.type === 'workshop' ? ' on ' : ' to discuss '}${topic}` : '', + WEEKDAY_DATE: date, TIME_TIMEZONE: time, GUEST_FORM_URL: input.formUrl, ACCESS_CODE: input.accessCode, + FIRST_NAME: firstName, TOPIC: topic }; +} +export function renderGuestFirstContact(template: string, input: GuestFirstContactInput) { + const rendered = renderTextTemplate(template, guestFirstContactValues(input)); + if (rendered.missing.length) throw new Error(`Invitation template is missing values: ${rendered.missing.join(', ')}`); + const match = /^Subject: ([^\r\n]+)\r?\n\r?\n([\s\S]+)$/.exec(rendered.text); + if (!match || !match[1].trim() || !match[2].trim()) throw new Error('Invitation template must contain Subject: followed by a blank line and body'); + return { from: PODCAST_FIRST_CONTACT_FROM, subject: match[1], body: match[2].replace(/\r\n/g, '\n').replace(/\n$/, ''), formUrl: input.formUrl, topicSeed: input.topic || '', accessCode: input.accessCode }; +} +export async function loadGuestFirstContact(db: DynamoDBDocumentClient, input: GuestFirstContactInput) { + const record = await getTextTemplatesRecord(db); + const item = record.templates.find(t => t.id === input.templateId); + if (!item) throw new Error(`Invitation text template ${input.templateId} is not configured`); + return { ...renderGuestFirstContact(item.body, input), templateId: item.id, templateUpdatedAt: item.updatedAt }; +} diff --git a/backend/src/events/guestFirstContactDelivery.ts b/backend/src/events/guestFirstContactDelivery.ts new file mode 100644 index 000000000..24ff22ed0 --- /dev/null +++ b/backend/src/events/guestFirstContactDelivery.ts @@ -0,0 +1,39 @@ +import type { SponsorDocEmailDelivery } from '../sponsorDocs/emailDelivery'; +import { randomUUID } from 'node:crypto'; +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getTaskConsistent, updateTask } from '../db/tasks'; +import { deliverRenderedEmail } from '../sponsorDocs/emailDelivery'; +import { loadGuestFirstContact, type GuestFirstContactInput } from './guestFirstContact'; + +/** Only reasons emitted before the Gmail send request are eligible for automatic recovery. */ +export function provenPreSendFailure(delivery: SponsorDocEmailDelivery | undefined): boolean { + if (!delivery || delivery.state !== 'deferred' || delivery.messageId) return false; + return ['missing_dapier_credential', 'dapier_unreachable', 'invalid_broker_json', 'empty_access_token', 'wrong_sender_identity', 'sender_identity_unverified'].includes(delivery.reason || '') || /^dapier_token_http_\d+$/.test(delivery.reason || ''); +} + +/** Saved Task claim precedes provider dispatch. Unknown outcomes are never retried automatically. */ +export async function deliverGuestFirstContact(db: DynamoDBDocumentClient, input: GuestFirstContactInput & { taskId: string; holdId: string; docId: string; recipient: string; actorId: string }) { + const task = await getTaskConsistent(db, input.taskId); + if (!task) throw new Error('Invitation Task is missing'); + const prior = task.podcastFirstContact; + if (prior?.state === 'sent') { + if (prior.holdId !== input.holdId || prior.docId !== input.docId || prior.recipient !== input.recipient) throw new Error('Invitation Task belongs to another invitation'); + return prior.delivery!; + } + const safeRetry = prior?.state === 'deferred' && provenPreSendFailure(prior.delivery) && prior.holdId === input.holdId && prior.docId === input.docId && prior.recipient === input.recipient; + if (prior && !safeRetry) throw new Error('Invitation delivery outcome requires reconciliation; automatic resend is blocked'); + const email = await loadGuestFirstContact(db, input); + const claim = { holdId: input.holdId, docId: input.docId, recipient: input.recipient, state: 'sending' as const, + claimedAt: new Date().toISOString(), attempts: prior ? [...(prior.attempts || []), { claimedAt: prior.claimedAt, delivery: prior.delivery! }] : [] }; + await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { podcastFirstContact: claim }, actorId: input.actorId, triggerKind: 'guest-first-contact-claim' }); + const result = await deliverRenderedEmail(db, { draft: email, recipientEmail: input.recipient, recipientName: input.guestName || '', taskId: task.id, + connectionId: process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID?.trim() || 'google-calendar-orin', cc: [] }); + const delivery = { ...result, templateId: email.templateId, templateRevision: email.templateUpdatedAt }; + const current = await getTaskConsistent(db, task.id); + if (!current) throw new Error('Invitation Task disappeared after provider delivery'); + await updateTask(db, task.id, { currentTask: current, expectedVersion: current.version, patch: { + podcastFirstContact: { ...claim, state: delivery.state === 'sent' ? 'sent' : 'deferred', delivery }, + notesLog: [...(current.notesLog || []), { id: randomUUID(), text: `Guest first-contact ${delivery.state}: ${input.recipient}; hold ${input.holdId}; doc ${input.docId}${delivery.messageId ? `; Gmail message ID: ${delivery.messageId}` : `; ${delivery.reason || 'check delivery'}`}`, authorId: input.actorId, createdAt: new Date().toISOString() }], + }, actorId: input.actorId, triggerKind: 'guest-first-contact-result' }); + return delivery; +} diff --git a/backend/src/events/guestIntakeAllocator.ts b/backend/src/events/guestIntakeAllocator.ts new file mode 100644 index 000000000..769e1c8bc --- /dev/null +++ b/backend/src/events/guestIntakeAllocator.ts @@ -0,0 +1,15 @@ +import { UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { TABLE_ARTIFACTS } from '../db/tableNames'; +const allocatorKey = { PK: 'GUESTINTAKEALLOCATOR', SK: 'GUESTINTAKEALLOCATOR' }; +/** Shared allocator lease covers availability read through durable Calendar proposal. Lambda timeout is 120s, lease 150s. */ +export async function claimGuestSlotAllocator(db: DynamoDBDocumentClient, id: string): Promise { + try { + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: allocatorKey, + UpdateExpression: 'SET leaseUntil = :lease, ownerId = :id', ConditionExpression: 'attribute_not_exists(leaseUntil) OR leaseUntil < :now', + ExpressionAttributeValues: { ':lease': Date.now() + 150000, ':now': Date.now(), ':id': id } })); + return true; + } catch (error) { if ((error as Error).name === 'ConditionalCheckFailedException') return false; throw error; } +} +export async function releaseAllocator(db: DynamoDBDocumentClient, id: string) { + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: allocatorKey, UpdateExpression: 'SET leaseUntil = :zero', ConditionExpression: 'ownerId = :id', ExpressionAttributeValues: { ':zero': 0, ':id': id } })); +} diff --git a/backend/src/events/guestIntakeCompletion.ts b/backend/src/events/guestIntakeCompletion.ts new file mode 100644 index 000000000..3450c0a31 --- /dev/null +++ b/backend/src/events/guestIntakeCompletion.ts @@ -0,0 +1,15 @@ +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getCardConsistent } from '../db/cards'; +import { getTaskConsistent, updateTask } from '../db/tasks'; +import { makeTaskHistoryEvent } from '../routes/taskValidation'; +/** Acceptance resolves the waiting first-invitation Task, including accepts racing delivery bookkeeping. */ +export async function resolveGuestAcceptanceTask(db: DynamoDBDocumentClient, cardId: string, eventId: string, actorId: string) { + const card = await getCardConsistent(db, cardId); + if (!card?.guestIntake) return; + const task = await getTaskConsistent(db, card.guestIntake.taskId); + if (!task || !['todo', 'waiting'].includes(task.status)) return; + const now = new Date().toISOString(); + await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { status: 'done', completedAt: now, completedBy: actorId, + waitingFor: null, followUpAt: null, followUpChannel: null, comment: `Guest accepted the event time; linked Calendar event ${eventId}.` }, + historyEvents: [makeTaskHistoryEvent('completed', task, { actorId, createdAt: now, note: `Guest time accepted for Calendar event ${eventId}.` })], actorId, triggerKind: 'guest-time-accepted' }); +} diff --git a/backend/src/events/guestIntakeCreate.ts b/backend/src/events/guestIntakeCreate.ts new file mode 100644 index 000000000..047f91c90 --- /dev/null +++ b/backend/src/events/guestIntakeCreate.ts @@ -0,0 +1,80 @@ +import { loadAssignmentPolicy } from '../db/workspaceSettings'; +import { createHash } from 'node:crypto'; +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getCardConsistent } from '../db/cards'; +import { listTasksByCardConsistent } from '../db/tasks'; +import { berlinToday } from '../calendar/ownerCalendarOverlay'; +import type { Template, Task, Card } from '../types'; +import { APPROVED_GUEST_EVENT_TYPES } from './guestIntakePolicy'; +import { resolveGuestContact, GuestContactInputError } from './guestContact'; +import { getGuestIntakeJob, guestIntakeKey, type GuestIntakeJob } from './guestIntakeStore'; +import { putNew, type PlannedWrite } from '../db/plannedWrite'; +import { TABLE_ARTIFACTS } from '../db/tableNames'; +const hash = (s: string) => createHash('sha256').update(s).digest('hex'); +function canonical(v: unknown): unknown { + if (Array.isArray(v)) return v.map(canonical); + if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).filter(([k]) => k !== 'creationKey').sort(([a], [b]) => a.localeCompare(b)).map(([k, value]) => [k, canonical(value)])); + return v; +} +export async function prepareGuestIntake(db: DynamoDBDocumentClient, actorId: string, template: Template, body: Record) { + if (!APPROVED_GUEST_EVENT_TYPES.has(template.type || '') || !template.guestIntake?.enabled) return null; + const names = Array.isArray(body.people) ? body.people as string[] : []; + const hasGuest = body.guestEmail !== undefined || body.guestPersonId || body.guestContactId || body.guestReference || body.guestName || names.length; + if (!hasGuest) return null; + if (body.skipEmail !== undefined && typeof body.skipEmail !== 'boolean') throw new Error('skipEmail must be boolean'); + const guest = await resolveGuestContact(db, { ...body, guestName: body.guestName || names[0] }); + for (const key of ['timeZone', 'guestTimezone'] as const) if (body[key] !== undefined && body[key] !== '') { + if (typeof body[key] !== 'string') throw new Error(`${key} must be an IANA time zone`); + try { new Intl.DateTimeFormat('en', { timeZone: body[key] as string }); } catch { throw new Error(`${key} must be an IANA time zone`); } + } + if (body.time !== undefined && (typeof body.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time))) throw new Error('time must be HH:MM'); + if (body.time && !body.anchorDate) throw new Error('An explicit time requires anchorDate'); + if (body.window !== undefined && (typeof body.window !== 'string' || !Object.hasOwn(template.eventPolicy?.windows || (template.base?.eventPolicy as import('./policy').EventPolicy | undefined)?.windows || {}, body.window))) throw new Error('window must name a configured event window'); + const fingerprint = hash(JSON.stringify(canonical({ ...body, templateId: template.id, guestEmail: guest.email, guestName: guest.name }))); + const creationKey = typeof body.creationKey === 'string' && body.creationKey.trim() ? body.creationKey.trim() : `auto:${fingerprint}`; + if (creationKey.length > 200) throw new Error('creationKey is too long'); + const id = hash(`${actorId}:${template.id}:${creationKey}`); + const existing = await getGuestIntakeJob(db, id); + if (existing && existing.fingerprint !== fingerprint) throw new Error('creationKey already belongs to a different Card request'); + const contactDefinition = template.taskDefinitions?.find(d => template.guestIntake?.firstContactTaskRefs?.includes(d.refId)); + const assignment = await loadAssignmentPolicy(db, [contactDefinition?.assigneeId]); + const intakeAssigneeId = assignment.assigneeFor(contactDefinition?.assigneeId); + const now = new Date().toISOString(); + const job: GuestIntakeJob = existing || { + id, cardId: '', taskId: `guest-intake-${id}`, actorId, type: template.type!, creationKey, fingerprint, + status: guest.email ? 'queued' : 'contact-pending', guestEmail: guest.email, guestName: guest.name, + topic: typeof body.topic === 'string' && body.topic.trim() ? body.topic.trim() : undefined, + skipEmail: body.skipEmail === true, + requestedDate: typeof body.anchorDate === 'string' ? body.anchorDate : undefined, + requestedTime: typeof body.time === 'string' ? body.time : undefined, + timeZone: typeof body.timeZone === 'string' ? body.timeZone : 'Europe/Berlin', + guestTimezone: typeof body.guestTimezone === 'string' && body.guestTimezone ? body.guestTimezone : undefined, + requestedWindow: typeof body.window === 'string' ? body.window : undefined, + policy: template.guestIntake, createdAt: now, updatedAt: now, + }; + async function replay() { + const stored = await getGuestIntakeJob(db, id); + if (!stored) return null; + if (stored.fingerprint !== fingerprint) throw new GuestContactInputError('creationKey already belongs to a different Card request', 409, 'creation_key_conflict'); + const card = await getCardConsistent(db, stored.cardId); + if (!card) throw new Error('Saved guest intake has no Card'); + return { ok: true as const, card, tasks: await listTasksByCardConsistent(db, card.id), template }; + } + return { + job, replay, + marker: { jobId: id, taskId: job.taskId, status: job.status, skipEmail: job.skipEmail, ...(guest.email ? { recipient: guest.email } : {}) }, + title: job.topic || (job.guestName ? `${job.guestName}: ${template.name}` : template.name), + companions(card: Card): PlannedWrite[] { + job.cardId = card.id; + return [putNew(TABLE_ARTIFACTS, guestIntakeKey(id), Object.fromEntries(Object.entries(job).filter(([,v]) => v !== undefined)))]; + }, + tasks(card: Card): Task[] { + return [{ id: job.taskId, cardId: card.id, version: 1, taskHistory: [], createdAt: now, updatedAt: now, + date: berlinToday(), status: 'todo', source: 'guest-event-intake', ...(intakeAssigneeId ? { assigneeId: intakeAssigneeId } : {}), + description: guest.email ? 'Prepare and send the guest invitation' : 'Add the guest contact email', + instructionDocId: 'system.programs-and-community.shared-event-operations', + comment: guest.email ? 'Invitation preparation is queued automatically after Card creation.' : 'The selected guest has no saved email. Add a verified contact email before sending.', + }]; + }, + }; +} diff --git a/backend/src/events/guestIntakePolicy.ts b/backend/src/events/guestIntakePolicy.ts new file mode 100644 index 000000000..0bf4cc215 --- /dev/null +++ b/backend/src/events/guestIntakePolicy.ts @@ -0,0 +1,28 @@ +export const APPROVED_GUEST_EVENT_TYPES = new Set(['podcast', 'webinar', 'workshop']); +export interface GuestIntakePolicy { + enabled: boolean; + emailTemplateId: string; + formTemplate: 'podcast' | 'webinar' | 'workshop'; + weekdays?: number[]; + minimumNoticeDays?: number; + preferredWindows?: string[]; + followUpDays: number; + firstContactTaskRefs?: string[]; +} +export function guestIntakePolicyIssues(raw: unknown, type: string): string[] { + if (raw === undefined) return []; + const p = raw as GuestIntakePolicy; + if (!p || typeof p !== 'object' || Array.isArray(p)) return ['guestIntake must be an object']; + const issues: string[] = []; + for (const key of Object.keys(p)) if (!['enabled','emailTemplateId','formTemplate','weekdays','minimumNoticeDays','preferredWindows','followUpDays','firstContactTaskRefs'].includes(key)) issues.push(`guestIntake.${key} is not supported`); + if (p.enabled && p.formTemplate !== type) issues.push('guestIntake.formTemplate must match the event type'); + if (typeof p.enabled !== 'boolean') issues.push('guestIntake.enabled must be boolean'); + if (p.enabled && !APPROVED_GUEST_EVENT_TYPES.has(type)) issues.push('Guest intake is approved only for podcast, webinar and workshop'); + if (!/^[a-z0-9][a-z0-9._-]+$/.test(p.emailTemplateId || '')) issues.push('guestIntake.emailTemplateId is required'); + if (!['podcast', 'webinar', 'workshop'].includes(p.formTemplate)) issues.push('guestIntake.formTemplate is invalid'); + if (!Number.isInteger(p.followUpDays) || p.followUpDays < 1 || p.followUpDays > 90) issues.push('guestIntake.followUpDays must be 1..90'); + if (p.weekdays && (!Array.isArray(p.weekdays) || !p.weekdays.length || p.weekdays.some(v => !Number.isInteger(v) || v < 0 || v > 6))) issues.push('guestIntake.weekdays must contain weekday numbers 0..6'); + if (p.minimumNoticeDays !== undefined && (!Number.isInteger(p.minimumNoticeDays) || p.minimumNoticeDays < 0 || p.minimumNoticeDays > 180)) issues.push('guestIntake.minimumNoticeDays must be 0..180'); + for (const key of ['preferredWindows', 'firstContactTaskRefs'] as const) if (p[key] && (!Array.isArray(p[key]) || p[key]!.some(v => typeof v !== 'string' || !v))) issues.push(`guestIntake.${key} must be string values`); + return issues; +} diff --git a/backend/src/events/guestIntakePortal.ts b/backend/src/events/guestIntakePortal.ts new file mode 100644 index 000000000..9c5f4b9da --- /dev/null +++ b/backend/src/events/guestIntakePortal.ts @@ -0,0 +1,150 @@ +import { getTaskConsistent, updateTask } from '../db/tasks'; +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import type { SponsorDoc } from '../db/sponsorDocs'; +import { getCardConsistent } from '../db/cards'; +import { getGuestIntakeJob } from './guestIntakeStore'; +import { guestEventSlots } from './guestIntakeSlots'; +import { resolveCardEventPolicy, approveEvent, rescheduleEvent, retryEventOperation } from './service'; +import { getEventRecord } from './store'; +import { claimGuestSlotAllocator, releaseAllocator } from './guestIntakeAllocator'; +import { PutCommand, ScanCommand, UpdateCommand, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { getSponsorDocWithAuthMaterial } from '../db/sponsorDocs'; +import { TABLE_ARTIFACTS } from '../db/tableNames'; + +export async function guestPortalSlots(db: DynamoDBDocumentClient, doc: SponsorDoc) { + const card = await getCardConsistent(db, doc.cardId); + if (!card?.guestIntake) return null; + const job = await getGuestIntakeJob(db, card.guestIntake.jobId); + if (!job?.policy) return null; + const { policy } = await resolveCardEventPolicy(db, card); + const slots = await guestEventSlots(db, { ...job, requestedDate: undefined, requestedTime: undefined }, policy, 8); + return slots.map(s => ({ date: s.date, window: s.window, localTime: s.time, startIso: s.startIso, endIso: s.endIso, label: s.label })); +} +export interface GuestTimeChoiceIntent { choice: string; proposalStartIso: string; proposalVersion: number } +const choiceKey = (id: string) => ({ PK: `GUESTCHOICE#${id}`, SK: `GUESTCHOICE#${id}` }); +class SupersededChoiceError extends Error {} +class InvalidChoiceError extends Error {} + +/** Only an explicit externally authenticated time action may record acceptance. */ +export async function applyGuestPortalTimeChoice( + db: DynamoDBDocumentClient, doc: SponsorDoc, intent: GuestTimeChoiceIntent, + assertCurrent: () => Promise, +): Promise { + const choice = intent.choice; + if (!choice || choice === 'none_fit') return; + const card = await getCardConsistent(db, doc.cardId); + if (!card?.guestIntake) return; + const job = await getGuestIntakeJob(db, card.guestIntake.jobId); + if (!job?.holdId || !job.guestEmail || job.docId !== doc.id) throw new Error('Guest invitation is still preparing; try shortly'); + let current = await getEventRecord(db, job.holdId); + if (!current || !['proposed', 'approved'].includes(current.status)) throw new InvalidChoiceError('This invitation is no longer active'); + if (card.stage !== 'preparation') throw new InvalidChoiceError('This event has been published; reply to Orin to arrange a schedule change'); + const moveKey = `guest-choice-${doc.id}-r${doc.version}`; + const acceptKey = `guest-accept-${doc.id}-r${doc.version}`; + const lastOperationKey = current.operations.at(-1)?.key; + const ownsTransition = lastOperationKey === moveKey || lastOperationKey === acceptKey; + if (!ownsTransition && (current.version !== intent.proposalVersion || current.startIso !== intent.proposalStartIso)) { + throw new InvalidChoiceError('The proposal changed; reload the form before choosing a time'); + } + await assertCurrent(); + if (current.operations.some(o => o.state !== 'complete')) { + const retried = await retryEventOperation(db, card.id, { actor: 'guest-portal' }); + if (retried.sync.state !== 'synced') throw new Error('Your time choice is saved; Calendar synchronization is pending'); + current = retried.event; + } + if (choice !== 'confirm_proposed') { + const match = /^slot:(\d{4}-\d{2}-\d{2}):([a-z][a-z0-9-]*)$/.exec(choice); + if (!match) throw new InvalidChoiceError('Choose a currently available time'); + const { policy } = await resolveCardEventPolicy(db, card); + const wantedTime = policy.windows[match[2]]; + if (current.date !== match[1] || current.time !== wantedTime) { + const available = await guestPortalSlots(db, doc); + if (!available?.some(s => s.date === match[1] && s.window === match[2])) throw new InvalidChoiceError('That time is no longer available; choose another time'); + await assertCurrent(); + const changed = await rescheduleEvent(db, card.id, { date: match[1], time: wantedTime, guestEmails: [job.guestEmail], guestTimezone: doc.content.guestTimezone || job.guestTimezone, + operationKey: moveKey, expectedEventVersion: current.version, actor: 'guest-portal' }); + if (changed.sync.state !== 'synced') throw new Error('Your time change is saved; Calendar synchronization is pending'); + current = changed.event; + } + } + await assertCurrent(); + if (current.status === 'proposed') { + const approved = await approveEvent(db, card.id, { operationKey: acceptKey, expectedEventVersion: current.version, actor: 'guest-portal' }); + if (approved.sync.state !== 'synced') throw new Error('Your acceptance is saved; Calendar synchronization is pending'); + } +} + +export async function processGuestChoice(db: DynamoDBDocumentClient, item: Record) { + const token = crypto.randomUUID(); + try { + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: choiceKey(item.docId), + UpdateExpression: 'SET leaseToken = :token, leaseUntil = :until', + ConditionExpression: '#v = :v AND #s = :pending AND (attribute_not_exists(leaseUntil) OR leaseUntil < :now)', + ExpressionAttributeNames: { '#v': 'version', '#s': 'status' }, + ExpressionAttributeValues: { ':v': item.version, ':pending': 'pending', ':token': token, ':until': Date.now()+150000, ':now': Date.now() } })); + } catch (error) { if ((error as Error).name === 'ConditionalCheckFailedException') return; throw error; } + let allocator = false; + const owner = `choice-${token}`; + const finish = async (status: string, error?: string) => { + try { + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: choiceKey(item.docId), + UpdateExpression: 'SET #s = :status, #error = :error REMOVE leaseToken, leaseUntil', + ConditionExpression: '#v = :v AND leaseToken = :token', + ExpressionAttributeNames: { '#s': 'status', '#v': 'version', '#error': 'error' }, + ExpressionAttributeValues: { ':status': status, ':error': error || '', ':v': item.version, ':token': token } })); + } catch (error) { if ((error as Error).name !== 'ConditionalCheckFailedException') throw error; } + }; + const assertCurrent = async () => { + const [saved, loaded] = await Promise.all([ + db.send(new GetCommand({ TableName: TABLE_ARTIFACTS, Key: choiceKey(item.docId), ConsistentRead: true })), + getSponsorDocWithAuthMaterial(db, item.docId), + ]); + if (saved.Item?.version !== item.version || saved.Item?.leaseToken !== token || saved.Item?.status !== 'pending' + || !loaded || loaded.doc.content.slotChoice !== item.choice) throw new SupersededChoiceError('A newer guest time choice superseded this action'); + }; + try { + await assertCurrent(); + allocator = await claimGuestSlotAllocator(db, owner); + if (!allocator) throw new Error('A time is being reserved; try shortly'); + const loaded = (await getSponsorDocWithAuthMaterial(db, item.docId))!; + await applyGuestPortalTimeChoice(db, { ...loaded.doc, version: item.version }, item.intent, assertCurrent); + await finish('complete'); + } catch (error) { + const message = (error as Error).message; + if (error instanceof SupersededChoiceError) await finish('cancelled', message); + else if (error instanceof InvalidChoiceError || /proposal changed|Event changed/.test(message)) { + await finish('failed', message); + const loaded = await getSponsorDocWithAuthMaterial(db, item.docId); + const card = loaded ? await getCardConsistent(db, loaded.doc.cardId) : null; + const task = card?.guestIntake ? await getTaskConsistent(db, card.guestIntake.taskId) : null; + if (task) await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { comment: `Guest time choice needs attention: ${message}` }, actorId: 'guest-portal', triggerKind: 'guest-time-choice-failed' }); + throw error; + } else { await finish('pending', message); throw error; } + } finally { if (allocator) await releaseAllocator(db, owner); } +} + +/** Persist explicit guest-origin intent before provider calls; ordinary bio saves never enter here. */ +export async function recordGuestPortalTimeChoice(db: DynamoDBDocumentClient, doc: SponsorDoc, raw: unknown) { + const intent = raw as GuestTimeChoiceIntent; + if (!intent || intent.choice !== doc.content.slotChoice || !Number.isInteger(intent.proposalVersion) || typeof intent.proposalStartIso !== 'string' || !Number.isFinite(Date.parse(intent.proposalStartIso))) throw new InvalidChoiceError('Reload the form and explicitly save your time choice'); + const card = await getCardConsistent(db, doc.cardId); + if (!card?.guestIntake) return; + const item = { ...choiceKey(doc.id), docId: doc.id, choice: intent.choice, intent, version: doc.version, status: intent.choice === 'none_fit' ? 'cancelled' : 'pending' }; + try { + await db.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: item, ConditionExpression: 'attribute_not_exists(PK) OR #v < :v', ExpressionAttributeNames: { '#v': 'version' }, ExpressionAttributeValues: { ':v': doc.version } })); + } catch (error) { if ((error as Error).name !== 'ConditionalCheckFailedException') throw error; } + if (item.status === 'pending') await processGuestChoice(db, item); +} +export async function resumeGuestPortalTimeChoices(db: DynamoDBDocumentClient) { + let cursor: Record | undefined; + let checked = 0; + do { + const page = await db.send(new ScanCommand({ TableName: TABLE_ARTIFACTS, ConsistentRead: true, FilterExpression: 'begins_with(PK, :prefix) AND #s = :pending', ExpressionAttributeNames: { '#s': 'status' }, ExpressionAttributeValues: { ':prefix': 'GUESTCHOICE#', ':pending': 'pending' }, ...(cursor ? { ExclusiveStartKey: cursor } : {}) })); + for (const item of page.Items || []) { + checked++; + try { await processGuestChoice(db, item); } catch { /* transient intent remains durable; permanent failure is visible on the owning Task */ } + } + cursor = page.LastEvaluatedKey; + } while (cursor); + return { checked }; +} diff --git a/backend/src/events/guestIntakeSlots.ts b/backend/src/events/guestIntakeSlots.ts new file mode 100644 index 000000000..8d1b41ee6 --- /dev/null +++ b/backend/src/events/guestIntakeSlots.ts @@ -0,0 +1,57 @@ +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { ownerCalendarOverlay, berlinToday } from '../calendar/ownerCalendarOverlay'; +import { cardEventInterval } from '../calendar/cardEventTime'; +import { listCalendarHolds } from '../db/calendarHolds'; +import { listCards } from '../db/cards'; +import { listPodcastEpisodes } from '../db/podcastEpisodes'; +import { addDays } from '../podcast/recordingSlots'; +import type { GuestIntakeJob } from './guestIntakeStore'; +import type { EventPolicy } from './policy'; +import { formatSlotLabel } from '../podcast/firstContact'; + +export function berlinParts(iso: string) { + const p = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(iso)).map(p => [p.type, p.value])); + return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour}:${p.minute}` }; +} +export interface GuestEventSlot { date: string; time: string; window: string; startIso: string; endIso: string; label: string } +export async function guestEventSlots(db: DynamoDBDocumentClient, job: GuestIntakeJob, policy: EventPolicy, count = 8): Promise { + const from = job.requestedDate || addDays(berlinToday(), job.policy?.minimumNoticeDays || 0); + const to = job.requestedDate || addDays(from, 59); + const [overlay, busy, holds, cards, episodes] = await Promise.all([ + ownerCalendarOverlay({ client: db, durationMinutes: policy.durationMinutes, window: 'any' }).project(from, to), + ownerCalendarOverlay({ client: db, includeAll: true, durationMinutes: policy.durationMinutes }).project(addDays(from, -1), addDays(to, 1)), + listCalendarHolds(db, { activeOnly: true }), listCards(db), listPodcastEpisodes(db), + ]); + if (overlay.connection.state !== 'connected' || (busy && busy.connection.state !== 'connected')) throw new Error('The authoritative owner calendar is unavailable; no time was invented'); + const overlaps = (a: number,b: number,c: number,d: number) => a < d && b > c; + const result: GuestEventSlot[] = []; + const windows = job.requestedWindow ? [job.requestedWindow] : [...(job.policy?.preferredWindows || []), ...Object.keys(policy.windows)].filter((v,i,a) => a.indexOf(v) === i); + for (let day = from; day <= to; day = addDays(day, 1)) { + for (const window of job.requestedTime ? ['explicit'] : windows) { + const interval = cardEventInterval(`${day} ${job.requestedTime || policy.windows[window]}`, policy.durationMinutes, job.requestedTime ? job.timeZone || 'Europe/Berlin' : policy.timeZone); + const parts = berlinParts(interval.startIso); + if ([0,6].includes(new Date(`${parts.date}T12:00:00Z`).getUTCDay())) continue; + if (job.policy?.weekdays?.length && !job.policy.weekdays.includes(new Date(`${parts.date}T12:00:00Z`).getUTCDay())) continue; + const start = Date.parse(interval.startIso) - policy.guests.leadMinutes * 60000, end = Date.parse(interval.endIso); + if (start <= Date.now()) continue; + if (!job.requestedTime && job.type === 'podcast' && !overlay.items.some(i => i.isAvailableSlot && i.startDate === day && i.slot === window)) continue; + if (busy?.items.some(i => !i.isAvailableSlot && (i.startsAt && i.endsAt ? overlaps(start,end,Date.parse(i.startsAt),Date.parse(i.endsAt)) : i.startDate <= parts.date && i.endDate >= parts.date))) continue; + if (holds.some(h => h.cardId !== job.cardId && overlaps(start,end,Date.parse(h.startIso) - policy.guests.leadMinutes * 60000,Date.parse(h.endIso)))) continue; + if (cards.some(c => c.id !== job.cardId && c.status !== 'archived' && c.eventTime && overlaps(start,end,Date.parse(c.eventTime.startIso) - policy.guests.leadMinutes * 60000,Date.parse(c.eventTime.endIso)))) continue; + if (episodes.some(e => { + if (e.cardId === job.cardId || ['released', 'expired'].includes(e.recordingEventStatus || '')) return false; + if (e.recordingStartIso && e.recordingEndIso) return overlaps(start,end,Date.parse(e.recordingStartIso),Date.parse(e.recordingEndIso)); + if (e.recordingDate && e.recordingTime) { + const occupied = cardEventInterval(`${e.recordingDate.slice(0,10)} ${e.recordingTime}`, 60, e.recordingTimeZone || 'Europe/Berlin'); + return overlaps(start,end,Date.parse(occupied.startIso),Date.parse(occupied.endIso)); + } + // Future booked episodes with no interval need reconciliation, not an invented free window. + return e.recordingDate?.slice(0,10) === parts.date && !e.recordingHoldId && !cards.some(c => c.id === e.cardId && c.eventTime); + })) continue; + result.push({ ...parts, window: window === 'explicit' ? job.requestedWindow || 'explicit' : window, startIso: interval.startIso, endIso: interval.endIso, label: formatSlotLabel(interval.startIso) }); + if (result.length >= count) return result; + } + } + if (job.requestedDate && !result.length) throw new Error('The requested date/time is unavailable or violates the event scheduling policy; choose another time'); + return result; +} diff --git a/backend/src/events/guestIntakeStore.ts b/backend/src/events/guestIntakeStore.ts new file mode 100644 index 000000000..76ce273ab --- /dev/null +++ b/backend/src/events/guestIntakeStore.ts @@ -0,0 +1,55 @@ +import { GetCommand, PutCommand, ScanCommand, UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { TABLE_ARTIFACTS } from '../db/tableNames'; +import type { GuestIntakePolicy } from './guestIntakePolicy'; +export type GuestIntakeStatus = 'queued' | 'preparing' | 'sent' | 'skipped' | 'failed' | 'contact-pending'; +export interface GuestIntakeJob { + id: string; cardId: string; taskId: string; actorId: string; type: string; + creationKey: string; fingerprint: string; status: GuestIntakeStatus; + guestEmail?: string; guestName?: string; topic?: string; skipEmail: boolean; + requestedDate?: string; requestedTime?: string; timeZone?: string; guestTimezone?: string; requestedWindow?: string; policy?: GuestIntakePolicy; + holdId?: string; docId?: string; selectedDate?: string; selectedTime?: string; selectedWindow?: string; + messageId?: string; sentAt?: string; startIso?: string; error?: string; retryable?: boolean; leaseUntil?: number; + createdAt: string; updatedAt: string; +} +export const guestIntakeKey = (id: string) => ({ PK: `GUESTINTAKE#${id}`, SK: `GUESTINTAKE#${id}` }); +export async function getGuestIntakeJob(db: DynamoDBDocumentClient, id: string): Promise { + const r = await db.send(new GetCommand({ TableName: TABLE_ARTIFACTS, Key: guestIntakeKey(id), ConsistentRead: true })); + if (!r.Item) return null; + const { PK, SK, ...job } = r.Item; + return job as GuestIntakeJob; +} +export async function saveGuestIntakeJob(db: DynamoDBDocumentClient, job: GuestIntakeJob): Promise { + job.updatedAt = new Date().toISOString(); + await db.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: { ...guestIntakeKey(job.id), ...job }, ...(job.leaseUntil ? { ConditionExpression: 'leaseUntil = :lease', ExpressionAttributeValues: { ':lease': job.leaseUntil } } : {}) })); +} +export async function claimGuestIntakeJob(db: DynamoDBDocumentClient, job: GuestIntakeJob): Promise { + const now = Date.now(); + try { + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: guestIntakeKey(job.id), + UpdateExpression: 'SET leaseUntil = :lease', ConditionExpression: 'attribute_exists(PK) AND (attribute_not_exists(leaseUntil) OR leaseUntil < :now) AND #s <> :sent AND #s <> :skipped', ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':lease': now + 150000, ':now': now, ':sent': 'sent', ':skipped': 'skipped' } })); + job.leaseUntil = now + 150000; + return true; + } catch (e) { if ((e as Error).name === 'ConditionalCheckFailedException') return false; throw e; } +} + +export async function listResumableGuestIntakes(db: DynamoDBDocumentClient): Promise { + const jobs: GuestIntakeJob[] = []; + let cursor: Record | undefined; + do { + const page = await db.send(new ScanCommand({ TableName: TABLE_ARTIFACTS, ConsistentRead: true, + FilterExpression: 'begins_with(PK, :prefix)', ExpressionAttributeValues: { ':prefix': 'GUESTINTAKE#' }, + ...(cursor ? { ExclusiveStartKey: cursor } : {}) })); + for (const item of page.Items || []) if (['queued', 'preparing', 'failed'].includes(item.status) && item.retryable !== false && (!item.leaseUntil || item.leaseUntil < Date.now())) { + const { PK, SK, ...job } = item; jobs.push(job as GuestIntakeJob); + } + cursor = page.LastEvaluatedKey; + } while (cursor); + return jobs; +} +export async function releaseGuestIntakeLease(db: DynamoDBDocumentClient, job: GuestIntakeJob) { + if (!job.leaseUntil) return; + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: guestIntakeKey(job.id), UpdateExpression: 'SET leaseUntil = :zero', + ConditionExpression: 'leaseUntil = :lease', ExpressionAttributeValues: { ':lease': job.leaseUntil, ':zero': 0 } })); + job.leaseUntil = 0; +} diff --git a/backend/src/events/guestIntakeWorker.ts b/backend/src/events/guestIntakeWorker.ts new file mode 100644 index 000000000..4b7bf7010 --- /dev/null +++ b/backend/src/events/guestIntakeWorker.ts @@ -0,0 +1,134 @@ +import { resolveGuestAcceptanceTask } from './guestIntakeCompletion'; +import { makeTaskHistoryEvent } from '../routes/taskValidation'; +import { resumeGuestPortalTimeChoices } from './guestIntakePortal'; +import { claimGuestSlotAllocator, releaseAllocator } from './guestIntakeAllocator'; +export { claimGuestSlotAllocator } from './guestIntakeAllocator'; +import { type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { TABLE_ARTIFACTS } from '../db/tableNames'; +import { getCardConsistent, updateCardAdditive } from '../db/cards'; +import { getTaskConsistent, updateTask, listTasksByCardConsistent } from '../db/tasks'; +import { createSponsorDoc } from '../db/sponsorDocs'; +import { getEventRecord } from './store'; +import { getGuestIntakeJob, saveGuestIntakeJob, claimGuestIntakeJob, releaseGuestIntakeLease, listResumableGuestIntakes, type GuestIntakeJob } from './guestIntakeStore'; +import { proposeEvent, rescheduleEvent, retryEventOperation, resolveCardEventPolicy, pollEventCalendarAcceptances } from './service'; +import { guestEventSlots } from './guestIntakeSlots'; +import { loadGuestFirstContact } from './guestFirstContact'; +import { deliverGuestFirstContact, provenPreSendFailure } from './guestFirstContactDelivery'; +import { getSponsorDocTemplate } from '../sponsorDocs/templates'; +import { addDays } from '../podcast/recordingSlots'; +import { berlinToday } from '../calendar/ownerCalendarOverlay'; +import { APPROVED_GUEST_EVENT_TYPES } from './guestIntakePolicy'; + +async function projectJob(db: DynamoDBDocumentClient, job: GuestIntakeJob) { + const card = await getCardConsistent(db, job.cardId); + if (!card) throw new Error('Invitation Card is missing'); + await updateCardAdditive(db, card, () => ({ guestIntake: { jobId: job.id, taskId: job.taskId, status: job.status, skipEmail: job.skipEmail, + ...(job.guestEmail ? { recipient: job.guestEmail } : {}), ...(job.messageId ? { messageId: job.messageId } : {}), ...(job.sentAt ? { sentAt: job.sentAt } : {}), ...(job.startIso ? { startIso: job.startIso } : {}), ...(job.error ? { error: job.error } : {}) } })); +} +async function recordFailure(db: DynamoDBDocumentClient, job: GuestIntakeJob, error: unknown) { + job.status = 'failed'; job.error = (error as Error).message || 'Invitation preparation failed'; + const task = await getTaskConsistent(db, job.taskId); + // Any durable dispatch claim without proven pre-provider failure needs reconciliation, never a blind resend. + const prior = task?.podcastFirstContact; + job.retryable = !(prior && prior.state !== 'sent' && !(prior.state === 'deferred' && provenPreSendFailure(prior.delivery))); + await saveGuestIntakeJob(db, job); + await projectJob(db, job); + if (task) await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { comment: `${job.error}. ${job.retryable ? 'DataOps will resume this same invitation automatically after the issue is fixed.' : 'Delivery outcome requires reconciliation before any retry.'}` }, actorId: job.actorId, triggerKind: 'guest-intake-failed' }); +} +export async function processGuestIntake(db: DynamoDBDocumentClient, id: string): Promise { + let job = await getGuestIntakeJob(db, id); + if (!job || !APPROVED_GUEST_EVENT_TYPES.has(job.type) || ['sent', 'skipped', 'contact-pending'].includes(job.status) || job.retryable === false) return; + if (!await claimGuestIntakeJob(db, job)) throw new Error('Invitation is already being prepared; retry after its lease'); + job = (await getGuestIntakeJob(db, id))!; + let allocator = false; + try { + const card = await getCardConsistent(db, job.cardId); + if (!card || card.status !== 'active') throw new Error('An active guest Card is required'); + if (!job.guestEmail || !job.policy?.enabled) throw new Error('Guest email and enabled event intake policy are required'); + job.status = 'preparing'; delete job.error; + await saveGuestIntakeJob(db, job); await projectJob(db, job); + const { policy } = await resolveCardEventPolicy(db, card); + // Intake owns exactly one first-contact letter; lifecycle transition templates must not send a second one. + if (Object.values(policy.notifications).some(n => n.emails.length)) throw new Error('Guest intake requires lifecycle email notifications to be empty; configure the editable first-contact template instead'); + const base = (process.env.DATAOPS_PORTAL_URL || 'https://ops.dtcdev.click').replace(/\/$/, ''); + const created = await createSponsorDoc(db, { id: `sdoc-${job.id.slice(0,24)}`, cardId: card.id, template: job.policy.formTemplate, title: card.title || 'Guest event', createdBy: job.actorId, + content: { ...(job.guestTimezone ? { guestTimezone: job.guestTimezone } : {}) } }); + job.docId = created.doc.id; + const formPath = `/${job.policy.formTemplate}-docs`; + const docUrl = `${base}${formPath}/${encodeURIComponent(job.docId)}`; + const formUrl = `${docUrl}?code=${encodeURIComponent(created.accessCode)}`; + const currentCard = (await getCardConsistent(db, card.id))!; + const linkName = getSponsorDocTemplate(job.policy.formTemplate).defaultLinkType; + await updateCardAdditive(db, currentCard, c => ({ cardLinks: [...(c.cardLinks || []).filter(l => l.name !== linkName), { name: linkName, url: docUrl }] })); + await saveGuestIntakeJob(db, job); + let event = await getEventRecord(db, `hold_${card.id}`); + if (!event) { + allocator = await claimGuestSlotAllocator(db, id); + if (!allocator) throw new Error('Another invitation is reserving a time; this invitation will resume automatically'); + const slots = await guestEventSlots(db, job, policy, 1); + if (!slots.length) throw new Error('No available event time was found; update the scheduling preference on the invitation Task'); + const slot = slots[0]; job.selectedDate = slot.date; job.selectedTime = slot.time; job.selectedWindow = slot.window; job.startIso = slot.startIso; + await saveGuestIntakeJob(db, job); + // Validate the editable copy before the first guest-facing Calendar operation. + if (!job.skipEmail) await loadGuestFirstContact(db, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: slot.startIso, guestTimezone: job.guestTimezone, formUrl, accessCode: created.accessCode }); + const result = await proposeEvent(db, card.id, { date: slot.date, time: slot.time, title: card.title, topic: job.topic, guestNames: job.guestName, guestTimezone: job.guestTimezone, guestEmails: [job.guestEmail], documentUrl: formUrl, + operationKey: `guest-intake-${id.slice(0,32)}`, actor: job.actorId, suppressGuestNotifications: job.skipEmail }); + if (result.sync.state !== 'synced') throw new Error(`Calendar invitation is pending: ${result.sync.reason || 'provider unavailable'}`); + event = result.event; + } else { + if (event.status !== 'proposed' && event.status !== 'approved') throw new Error('The linked guest event was released; reconcile it before sending'); + const op = event.operations.at(-1); + if (op && op.state !== 'complete') { + const result = await retryEventOperation(db, card.id, { operationKey: op.key, actor: job.actorId }); + if (result.sync.state !== 'synced') throw new Error(`Calendar invitation is pending: ${result.sync.reason || 'provider unavailable'}`); + event = result.event; + } + // An explicit initial invitation after skip reuses the same private event and restores guest notifications. + if (!job.skipEmail && !event.policy.guests.proposal && event.status === 'proposed') { + const notified = await rescheduleEvent(db, card.id, { date: event.date, time: event.time, guestEmails: [job.guestEmail], documentUrl: formUrl, operationKey: `guest-intake-notify-${id.slice(0,32)}`, actor: job.actorId }); + if (notified.sync.state !== 'synced') throw new Error(`Calendar invitation is pending: ${notified.sync.reason || 'provider unavailable'}`); + event = notified.event; + } + } + job.holdId = event.id; job.startIso = event.startIso; + await saveGuestIntakeJob(db, job); + if (allocator) { await releaseAllocator(db, id); allocator = false; } + if (job.skipEmail) { + job.status = 'skipped'; await saveGuestIntakeJob(db, job); await projectJob(db, job); return; + } + const delivery = await deliverGuestFirstContact(db, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: event.startIso, guestTimezone: job.guestTimezone, + formUrl, accessCode: created.accessCode, taskId: job.taskId, holdId: event.id, docId: job.docId, recipient: job.guestEmail, actorId: job.actorId }); + if (delivery.state !== 'sent' || !delivery.messageId) throw new Error(`First-contact email is pending: ${delivery.reason || delivery.state}`); + job.messageId = delivery.messageId; job.sentAt = delivery.sentAt || new Date().toISOString(); delete job.error; + const task = await getTaskConsistent(db, job.taskId); + const latestEvent = await getEventRecord(db, event.id); + if (latestEvent?.status === 'approved') await resolveGuestAcceptanceTask(db, card.id, event.id, job.actorId); + else if (task) await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { status: 'waiting', waitingFor: job.guestEmail, followUpAt: addDays(berlinToday(), job.policy.followUpDays), comment: `Invitation sent to ${job.guestEmail}; Gmail message ID ${delivery.messageId}. Waiting for the guest's time choice.` }, historyEvents: task.status !== 'waiting' ? [makeTaskHistoryEvent('waiting-started', task, { actorId: job.actorId, createdAt: new Date().toISOString(), note: 'First invitation delivered; waiting for guest time choice.' })] : [], actorId: job.actorId, triggerKind: 'guest-intake-sent' }); + const proofUrl = `https://mail.google.com/mail/u/orin@datatalks.club/#all/${encodeURIComponent(delivery.messageId)}`; + const sentCard = (await getCardConsistent(db, card.id))!; + await updateCardAdditive(db, sentCard, c => ({ cardLinks: [...(c.cardLinks || []).filter(l => l.name !== 'Guest email'), { name: 'Guest email', url: proofUrl }] })); + const tasks = await listTasksByCardConsistent(db, card.id); + for (const ref of job.policy.firstContactTaskRefs || []) { + const manual = tasks.find(t => t.templateTaskRef === ref); + if (!manual || !['todo','waiting'].includes(manual.status)) continue; + await updateTask(db, manual.id, { currentTask: manual, expectedVersion: manual.version, patch: { status: 'done', completedAt: new Date().toISOString(), completedBy: job.actorId, link: proofUrl, comment: `First invitation delivered by intake Task ${job.taskId}; Gmail message ID ${delivery.messageId}; Calendar event ${event.id}.` }, historyEvents: [makeTaskHistoryEvent('completed', manual, { actorId: job.actorId, createdAt: new Date().toISOString(), note: `Automated invitation proof: ${proofUrl}` })], actorId: job.actorId, triggerKind: 'guest-intake-automated-step' }); + } + job.status = 'sent'; await saveGuestIntakeJob(db, job); await projectJob(db, job); + } catch (error) { await recordFailure(db, job, error); throw error; } + finally { if (allocator) await releaseAllocator(db, id); await releaseGuestIntakeLease(db, job); } +} +export async function resumeGuestIntakes(db: DynamoDBDocumentClient) { + const jobs = await listResumableGuestIntakes(db); + const outcomes: Array<{ id: string; ok: boolean }> = []; + // Bound each scheduled invocation; unprocessed durable rows remain for the next run. + for (const job of jobs.sort((a,b) => a.updatedAt.localeCompare(b.updatedAt)).slice(0, 1)) { + try { await processGuestIntake(db, job.id); outcomes.push({ id: job.id, ok: true }); } + catch { outcomes.push({ id: job.id, ok: false }); } + } + return { checked: outcomes.length, outcomes }; +} +export async function pollGuestIntakeAcceptances(db: DynamoDBDocumentClient) { + // Includes new explicit standalone event proposals, never historical proposals lacking the marker. + await resumeGuestPortalTimeChoices(db); + return pollEventCalendarAcceptances(db, { automaticOnly: true }); +} diff --git a/backend/src/events/lifecycle.ts b/backend/src/events/lifecycle.ts index 1cf157d7c..1a774b2be 100644 --- a/backend/src/events/lifecycle.ts +++ b/backend/src/events/lifecycle.ts @@ -1,3 +1,4 @@ +import { formatSlotLabel } from '../podcast/firstContact'; import { requireEventBytes } from './size'; import { createHash } from 'node:crypto'; import { cardEventInterval } from '../calendar/cardEventTime'; @@ -48,6 +49,8 @@ export interface EventRecord { startIso: string; endIso: string; durationMinutes: number; + guestTimezone?: string; + autoAcceptancePoll?: boolean; expiresAt: string; policy: EventPolicy; renderValues: EventRenderValues; @@ -76,6 +79,7 @@ export interface EventCommand { guestNames: string; documentUrl: string; guestEmails: string[]; + guestTimezone?: string; }; approval?: { via: 'calendar-accept'; attendeeEmail: string; acceptedEmails: string[]; generation: number }; } @@ -136,6 +140,8 @@ export function planEventTransition(id: string, cardId: string, current: EventRe status: 'proposed', date: command.date!, time, ...interval, expiresAt: new Date(Date.parse(command.now) + policy.expiresDays * 86400000).toISOString(), policy, guestEmails, + autoAcceptancePoll: policy.calendarAcceptance.enabled, + ...((proposal.guestTimezone || current?.guestTimezone) ? { guestTimezone: proposal.guestTimezone || current?.guestTimezone } : {}), renderValues: { title: proposal.title, topic: proposal.topic, guestNames: proposal.guestNames, date: command.date!, time, timeZone: policy.timeZone, documentUrl: proposal.documentUrl, @@ -162,13 +168,18 @@ export function planEventTransition(id: string, cardId: string, current: EventRe const inviteGuests = approved ? policy.guests.approved : policy.guests.proposal; const attendees = inviteGuests ? emails([...next.guestEmails, ...policy.guests.requiredEmails]) : []; const render = (text: string) => renderEventText(text, { ...renderValues, zoomUrl: approved ? renderValues.zoomUrl : '' }); + let guestDetails = ''; + if (next.guestTimezone) guestDetails = `Guest local time: ${formatSlotLabel(next.startIso, next.guestTimezone)} (${next.guestTimezone})`; + let formCode = ''; + try { formCode = new URL(renderValues.documentUrl).searchParams.get('code') || ''; } catch { /* no linked guest form */ } + if (formCode) guestDetails += `${guestDetails ? '\n' : ''}Guest form: ${renderValues.documentUrl}\nAccess code: ${formCode}`; next.operations.push({ key: command.operationKey, fingerprint: hash, transition, state: 'pending', desired: { summary: render(approved ? policy.titles.approved : policy.titles.proposed), - description: [render(policy.description), render(policy.inviteText)].filter(Boolean).join('\n\n'), + description: [render(policy.description), render(policy.inviteText), guestDetails].filter(Boolean).join('\n\n'), location: approved && policy.zoom.enabled ? policy.zoom.url! : '', attendees, - startIso: new Date(Date.parse(next.startIso) - (approved ? policy.guests.leadMinutes : 0) * 60000).toISOString(), + startIso: new Date(Date.parse(next.startIso) - policy.guests.leadMinutes * 60000).toISOString(), endIso: next.endIso, timeZone: next.timeZone, sendUpdates: policy.notifications[transition].calendar, emails: policy.notifications[transition].emails.map(email => ({ diff --git a/backend/src/events/policy.ts b/backend/src/events/policy.ts index 48f5e0a11..0cd917926 100644 --- a/backend/src/events/policy.ts +++ b/backend/src/events/policy.ts @@ -60,9 +60,9 @@ export function eventPolicyIssues(raw: unknown): string[] { } } const titles = object(p.titles, 'titles', ['proposed', 'approved']); - if (text(titles.proposed, 'titles.proposed', true, 500) && !titles.proposed.includes('(Proposal)')) fail('titles.proposed', 'must include (Proposal)'); + if (text(titles.proposed, 'titles.proposed', true, 500) && !titles.proposed.includes('(Proposal)') && !titles.proposed.startsWith('[Proposed] ')) fail('titles.proposed', 'must start with [Proposed] (legacy (Proposal) is accepted)'); text(titles.approved, 'titles.approved', true, 500); - if (typeof titles.approved === 'string' && titles.approved.includes('(Proposal)')) fail('titles.approved', 'must not include (Proposal)'); + if (typeof titles.approved === 'string' && (titles.approved.includes('(Proposal)') || titles.approved.includes('[Proposed]'))) fail('titles.approved', 'must not include a proposal prefix'); text(p.description, 'description', false); text(p.inviteText, 'inviteText', false); const guests = object(p.guests, 'guests', ['proposal', 'approved', 'requiredEmails', 'leadMinutes']); diff --git a/backend/src/events/service.ts b/backend/src/events/service.ts index 30ae0a5dc..73fc20ddc 100644 --- a/backend/src/events/service.ts +++ b/backend/src/events/service.ts @@ -1,3 +1,4 @@ +import { resolveGuestAcceptanceTask } from './guestIntakeCompletion'; import { randomUUID } from 'node:crypto'; import { GetCommand, @@ -642,6 +643,7 @@ export interface EventTransitionInput { title?: string; topic?: string; guestNames?: string; + guestTimezone?: string; guestEmails?: string[]; documentUrl?: string; operationKey?: string; @@ -649,6 +651,10 @@ export interface EventTransitionInput { now?: string; deps?: HoldGoogleDeps; approval?: EventCommand['approval']; + /** Internal creation orchestration: one skipEmail suppresses both outbound channels. */ + suppressGuestNotifications?: boolean; + /** Bind an external guest action to the exact proposal they saw. */ + expectedEventVersion?: number; } async function runTransition( @@ -660,6 +666,7 @@ async function runTransition( const actor = input.actor || 'authenticated-operator'; const now = input.now || new Date().toISOString(); const { card, episode, event: current, holdId } = await resolveEventTarget(client, targetId); + if (input.expectedEventVersion !== undefined && current?.version !== input.expectedEventVersion) throw new EventConflictError('The proposal changed; reload the guest form before accepting a time'); const deps = input.deps || (await calendarInviteDeps({}, client)); // If the same operationKey was already committed and is pending/deferred, reconcile it @@ -687,6 +694,20 @@ async function runTransition( let command: EventCommand; if (transition === 'propose' || transition === 'reschedule') { const { templateId, templateVersion, policy } = await resolveCardEventPolicy(client, card); + // Newly proposed events use the current label; existing records keep their saved policy until a transition. + policy.titles.proposed = '[Proposed] ' + policy.titles.proposed.replace(/^(?:\(Proposal\)|\[Proposed\])\s*/i, ''); + if (input.suppressGuestNotifications) { + policy.guests.proposal = false; + policy.calendarAcceptance.enabled = false; + policy.notifications.propose.calendar = 'none'; + policy.notifications.reschedule.calendar = 'none'; + policy.notifications.propose.emails = []; + policy.notifications.reschedule.emails = []; + } else if (input.guestEmails?.length) { + policy.guests.proposal = true; + policy.calendarAcceptance.enabled = true; + policy.notifications.propose.calendar = 'all'; + } const guestEmails = await resolveProposalGuests( client, card, @@ -743,6 +764,7 @@ async function runTransition( guestNames, documentUrl, guestEmails, + ...(input.guestTimezone ? { guestTimezone: input.guestTimezone } : {}), }, }; } else { @@ -874,7 +896,9 @@ export async function approveEvent( targetId: string, input: EventTransitionInput = {}, ): Promise { - return runTransition(client, 'approve', targetId, input); + const result = await runTransition(client, 'approve', targetId, input); + if (result.event.status === 'approved') await resolveGuestAcceptanceTask(client, result.card.id, result.event.id, input.actor || 'authenticated-operator'); + return result; } export async function rescheduleEvent( @@ -974,7 +998,7 @@ export interface CalendarAcceptancePollOutcome { */ export async function pollEventCalendarAcceptances( client: DynamoDBDocumentClient, - options: { targetId?: string; deps?: HoldGoogleDeps; now?: string } = {}, + options: { targetId?: string; deps?: HoldGoogleDeps; now?: string; automaticOnly?: boolean } = {}, ): Promise<{ checked: number; outcomes: CalendarAcceptancePollOutcome[] }> { const deps = options.deps || (await calendarInviteDeps({}, client)); const now = options.now || new Date().toISOString(); @@ -1004,6 +1028,7 @@ export async function pollEventCalendarAcceptances( const outcomes: CalendarAcceptancePollOutcome[] = []; for (const record of candidates) { + if (options.automaticOnly && record.autoAcceptancePoll !== true) continue; if (record.status !== 'proposed') { outcomes.push({ holdId: record.id, @@ -1093,10 +1118,9 @@ export async function pollEventCalendarAcceptances( } } - if ( - live.start?.dateTime && - Date.parse(live.start.dateTime) !== Date.parse(record.startIso) - ) { + const desiredInterval = record.operations.at(-1)?.desired; + if ((live.start?.dateTime && Date.parse(live.start.dateTime) !== Date.parse(desiredInterval?.startIso || record.startIso)) + || (live.end?.dateTime && Date.parse(live.end.dateTime) !== Date.parse(desiredInterval?.endIso || record.endIso))) { outcomes.push({ holdId: record.id, cardId: record.cardId, diff --git a/backend/src/handler.ts b/backend/src/handler.ts index 9dad4e565..831465969 100644 --- a/backend/src/handler.ts +++ b/backend/src/handler.ts @@ -1,3 +1,4 @@ +import { processGuestIntake, resumeGuestIntakes, pollGuestIntakeAcceptances } from './events/guestIntakeWorker'; import { renderBannerJob } from './banners/service'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { route } from './router'; @@ -428,6 +429,14 @@ async function handleInvocation( }), }; } + if (detail?.dataopsAction === 'guest-event-intake-resume') { + const result = await resumeGuestIntakes(client!); + return { statusCode: 200, headers: {}, body: JSON.stringify(result) }; + } + if (detail?.dataopsAction === 'guest-event-acceptance-poll') { + const result = await pollGuestIntakeAcceptances(client!); + return { statusCode: 200, headers: {}, body: JSON.stringify(result) }; + } if (detail?.dataopsAction === 'calendar-holds-expire') { const dryRun = detail.dryRun === true; const nowIso = typeof detail.now === 'string' && detail.now.trim() ? detail.now.trim() : undefined; @@ -461,6 +470,15 @@ async function handler( context?: unknown ): Promise { const records = (event as { Records?: { eventSource?: string; eventSourceARN?: string; body?: string }[] }).Records; + if (Array.isArray(records) && records.length > 0 && records.every(record => record.eventSource === 'aws:dynamodb')) { + const db = await getClient(); + for (const raw of records) { + const record = raw as unknown as { eventName: string; dynamodb?: { NewImage?: { guestIntake?: { M?: { jobId?: { S?: string } } } } } }; + const jobId = record.dynamodb?.NewImage?.guestIntake?.M?.jobId?.S; + if (record.eventName === 'INSERT' && jobId && /^[a-f0-9]{64}$/.test(jobId)) await processGuestIntake(db, jobId); + } + return { statusCode: 200, headers: {}, body: '{}' }; + } if (Array.isArray(records) && records.length > 0 && records.every(record => record.eventSource === 'aws:sqs' && record.eventSourceARN === process.env.BANNER_QUEUE_ARN)) { const db = await getClient(); for (const record of records) { diff --git a/backend/src/podcast/firstContact.ts b/backend/src/podcast/firstContact.ts index db76754fc..cc3b9ec14 100644 --- a/backend/src/podcast/firstContact.ts +++ b/backend/src/podcast/firstContact.ts @@ -1,146 +1,9 @@ -/** - * Podcast first-contact email (Orin's ask). - * - * A pure renderer: given the guest's first name and exactly one proposed - * recording slot, it returns the send-ready first-contact email sent from - * Orin's mailbox. It creates nothing: no Card, no hold, no draft. The caller - * (CLI `dataops podcast invite`) creates the CalendarHold (Google Proposal) - * and the standalone follow-up Task separately; this module only renders copy. - * - * Rules (literal acceptance criteria, t518u): - * - from is always orin@datatalks.club; - * - exactly one proposed slot (no slot list, no alternate picker in email); - * - do NOT ask the guest to invent alternate times in email — slot changes - * go through the guest materials form (confirm / free slots / none-fit); - * - topics/bio/slot confirm via the guest form only (no email reply-instead path); - * - if a topic is already known, seed the email from that topic and do not ask for topic ideas; - * - links the public podcast docs page and (when available) the guest form; - * - mentions no Card and returns no card reference. - */ - +/** Approved first-contact copy. Rendering creates no records and sends no mail. */ export const PODCAST_FIRST_CONTACT_FROM = 'orin@datatalks.club'; - -export const PODCAST_FIRST_CONTACT_SENDER_NAME = 'Orin'; - -export const PODCAST_DOCS_URL = - 'https://datatalksclub.github.io/docs/activities/podcast/'; - -export interface PodcastFirstContactInput { - /** Guest's first name, used in the subject and greeting. */ - guestFirstName: string; - /** - * The single proposed recording slot as a human label, e.g. - * "Tuesday, November 4 at 12:30 CET". Exactly one slot: the guest form - * (not this email) collects alternates / free-slot picks. - */ - slotLabel: string; - /** Public guest materials form URL (with access code when available). */ - formUrl?: string; - /** - * When the episode topic is already known (e.g. Ian), seed the email from - * that topic instead of asking the guest to invent one in email. - */ - topicSeed?: string; - /** Override the docs link (tests only); production uses PODCAST_DOCS_URL. */ - docsUrl?: string; - /** Override the sender display name (tests only). */ - senderName?: string; -} - -export interface PodcastFirstContactEmail { - from: string; - subject: string; - body: string; - docsUrl: string; - formUrl: string | null; - topicSeed: string | null; -} +export const PODCAST_DOCS_URL = 'https://datatalksclub.github.io/docs/activities/podcast/'; const BERLIN_TZ = 'Europe/Berlin'; -function clean(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -/** - * Render the first-contact email. Throws on missing/blank inputs so callers - * fail loudly instead of sending unresolved template text. - */ -export function renderPodcastFirstContactEmail( - input: PodcastFirstContactInput, -): PodcastFirstContactEmail { - const guestFirstName = clean(input.guestFirstName); - const slotLabel = clean(input.slotLabel); - const senderName = clean(input.senderName) || PODCAST_FIRST_CONTACT_SENDER_NAME; - const docsUrl = clean(input.docsUrl) || PODCAST_DOCS_URL; - const formUrl = clean(input.formUrl) || null; - const topicSeed = clean(input.topicSeed) || null; - - if (!guestFirstName) throw new Error('guestFirstName is required'); - if (guestFirstName.length > 100) throw new Error('guestFirstName is too long'); - if (!slotLabel) throw new Error('slotLabel is required: propose exactly one slot'); - if (slotLabel.length > 200) throw new Error('slotLabel is too long'); - if (!/^https:\/\//.test(docsUrl)) throw new Error('docsUrl must be an https URL'); - if (formUrl && !/^https:\/\//.test(formUrl)) throw new Error('formUrl must be an https URL'); - if (topicSeed && topicSeed.length > 300) throw new Error('topicSeed is too long'); - - const subject = `${guestFirstName} at DataTalks.Club Podcast`; - const lines = [ - `Hi ${guestFirstName},`, - '', - `Nice to meet you! I'm ${senderName} and I'm taking care of organizing events at DataTalks.Club.`, - '', - 'In case you don\'t know about our podcasts, you can read more about them here:', - '', - docsUrl, - '', - ]; - - if (topicSeed) { - lines.push( - `We'd love to record an episode with you about ${topicSeed}.`, - '', - ); - } - - lines.push( - 'Please share anything you published online that you think will help us prepare for the interview. For example:', - '', - '1. Blog posts', - '2. Talks and podcast interviews', - '3. Social media posts', - '4. Github repositories', - '', - `For the interview, will ${slotLabel} work for you?`, - '', - ); - - if (formUrl) { - lines.push( - 'Please use this form to confirm that time, pick another free recording slot if needed, share a bio, and (if we have not already set a topic) the topics you would most enjoy covering. The form lists ordinary free Mon/Tue slots; if none fit, just reply to this email and we will find an alternative:', - '', - formUrl, - '', - ); - } else { - lines.push( - 'Once we have a guest form link for you, please use it to confirm the time, pick another free recording slot if needed, share a bio, and (if we have not already set a topic) the topics you would most enjoy covering. Please do not invent alternate days/times in email; we will share free ordinary slots (Mon/Tue midday 12:30 or evening 17:00 Europe/Berlin) via the form.', - '', - ); - } - - lines.push('Best regards,', 'Orin Vale'); - - return { - from: PODCAST_FIRST_CONTACT_FROM, - subject, - body: lines.join('\n'), - docsUrl, - formUrl, - topicSeed, - }; -} - /** Berlin wall-clock label for a hold instant, e.g. "Tuesday, November 4 at 12:30 CET". */ export function formatSlotLabel(startIso: string, timeZone = BERLIN_TZ): string { const instant = Date.parse(startIso); diff --git a/backend/src/routes/calendarHolds.ts b/backend/src/routes/calendarHolds.ts index e92f59f4f..b9dd39e81 100644 --- a/backend/src/routes/calendarHolds.ts +++ b/backend/src/routes/calendarHolds.ts @@ -75,10 +75,7 @@ async function createRoute(event: LambdaEvent, client: DynamoDBDocumentClient): const body = parse(event); if (!body || typeof body !== 'object' || Array.isArray(body)) return json(400, { error: 'Request body is required' }); if (typeof body.cardId === 'string') { - const card = await getCardConsistent(client, body.cardId); - if (card?.eventTime || card?.calendarInvites?.length) { - return json(409, { error: 'This Card already owns an invitation. Change its time with dataops cards time.' }); - } + return json(422, { error: 'Card-linked proposals use dataops event propose CARD_ID so the invitation stays linked and updateable. Standalone calendar propose creates only a private hold.', code: 'use_card_event_proposal' }); } let hold; try { diff --git a/backend/src/routes/cards.ts b/backend/src/routes/cards.ts index 3352b130d..32d0f0d03 100644 --- a/backend/src/routes/cards.ts +++ b/backend/src/routes/cards.ts @@ -1,3 +1,11 @@ +import { getSponsorDocWithAuthMaterial, decryptSponsorDocAccessCode } from '../db/sponsorDocs'; +import { getEventRecord } from '../events/store'; +import { loadGuestFirstContact } from '../events/guestFirstContact'; +import { provenPreSendFailure } from '../events/guestFirstContactDelivery'; +import { getTaskConsistent } from '../db/tasks'; +import { PutCommand } from '@aws-sdk/lib-dynamodb'; +import { TABLE_ARTIFACTS } from '../db/tableNames'; +import { getGuestIntakeJob, guestIntakeKey } from '../events/guestIntakeStore'; import { cardEventInterval } from '../calendar/cardEventTime'; import { adoptCardInvite, adoptEventDate, CardInviteConflictError, CardInviteGoogleError, CardInviteInputError, cardGuests, cardHasHoldInvite, parseCardGuests, syncCardInvite, unlinkCardInvite, verifyCardInvites } from '../calendar/cardInvite'; import { lumaUrlForEventCard } from '../calendar/lumaEvents'; @@ -374,6 +382,40 @@ async function handleCardRoutes( ); } + const firstInviteMatch = suffix.match(/^\/([^/]+)\/guest-intake(?:\/(send|retry))?\/?$/); + if (firstInviteMatch) { + const action = firstInviteMatch[2]; + if (method !== (action ? 'POST' : 'GET')) return json(405, { error: 'Method not allowed' }); + const actor = await resolveInteractiveActor(client, event, action ? 'work-write' : 'work-read'); + if (!actor.ok) return actor.response; + const card = await getCardConsistent(client, firstInviteMatch[1]); + if (!card) return json(404, { error: 'Card not found' }); + const job = card.guestIntake ? await getGuestIntakeJob(client, card.guestIntake.jobId) : null; + if (!job) return json(404, { error: 'This Card has no creation invitation' }); + if (!action) { + let email; + if (event.queryStringParameters?.preview === '1' && job.docId && job.holdId && job.policy) { + const [loaded, hold] = await Promise.all([getSponsorDocWithAuthMaterial(client, job.docId), getEventRecord(client, job.holdId)]); + const code = loaded ? decryptSponsorDocAccessCode(job.docId, loaded.accessCodeCiphertext) : null; + if (loaded && hold && code) email = await loadGuestFirstContact(client, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: hold.startIso, guestTimezone: job.guestTimezone, + formUrl: `${(process.env.DATAOPS_PORTAL_URL || 'https://ops.dtcdev.click').replace(/\/$/, '')}/${job.policy.formTemplate}-docs/${encodeURIComponent(job.docId)}?code=${encodeURIComponent(code)}`, accessCode: code }); + } + return json(200, { invitation: job, ...(email ? { preview: true, email: { ...email, to: job.guestEmail, cc: [] } } : {}) }); + } + if (!mayAdministerCard(actor.actor, card)) return workAdminForbidden('Sending this invitation requires the Card owner or an admin'); + if (action === 'send' && (job.status !== 'skipped' || !job.skipEmail)) return json(409, { error: 'Only a skipped initial invitation can be sent here; existing delivery attempts must be reconciled' }); + if (action === 'retry') { + const task = await getTaskConsistent(client, job.taskId); + const prior = task?.podcastFirstContact; + if (job.status !== 'failed' || job.retryable !== true || (prior && prior.state !== 'sent' && !provenPreSendFailure(prior.delivery))) return json(409, { error: 'Only a proven safe preparation or pre-send failure can be retried; reconcile uncertain delivery first' }); + } + const skipEmail = action === 'send' ? false : job.skipEmail; + await client.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: { ...guestIntakeKey(job.id), ...job, status: 'queued', skipEmail, updatedAt: new Date().toISOString() }, + ConditionExpression: '#s = :prior AND updatedAt = :updated', ExpressionAttributeNames: { '#s': 'status' }, ExpressionAttributeValues: { ':prior': job.status, ':updated': job.updatedAt } })); + const updated = await updateCardAdditive(client, card, c => ({ guestIntake: { ...c.guestIntake!, status: 'queued', skipEmail } })); + return json(202, { card: updated, invitation: { status: 'queued', recipient: job.guestEmail, taskId: job.taskId } }); + } + // Route: /api/cards/:id/tasks const tasksMatch = suffix.match(/^\/([^/]+)\/tasks\/?$/); if (tasksMatch) { diff --git a/backend/src/routes/podcastFirstContact.ts b/backend/src/routes/podcastFirstContact.ts index ae7d5b8b9..8b9c90e25 100644 --- a/backend/src/routes/podcastFirstContact.ts +++ b/backend/src/routes/podcastFirstContact.ts @@ -1,3 +1,4 @@ +import { loadGuestFirstContact } from '../events/guestFirstContact'; /** * POST /api/podcast-first-contact/email: render Orin's first-contact email. * @@ -10,11 +11,6 @@ import { randomUUID } from 'node:crypto'; import { resolveInteractiveActor } from '../identity/actor'; import type { LambdaEvent, LambdaResponse } from '../types'; -import { - PODCAST_DOCS_URL, - renderPodcastFirstContactEmail, - formatSlotLabel, -} from '../podcast/firstContact'; import { noStoreJson as json, parseJsonObjectEventOrEmpty as parse } from '../http'; import { getTaskConsistent, updateTask, TaskVersionConflictError } from '../db/tasks'; import { getCalendarHold } from '../db/calendarHolds'; @@ -78,7 +74,7 @@ export async function handlePodcastFirstContactRoutes( const formUrl = `${sponsorDocPortalBaseUrl(event)}/podcast-docs/${encodeURIComponent(doc.id)}?code=${encodeURIComponent(accessCode)}`; let email; try { - email = renderPodcastFirstContactEmail({ guestFirstName: guestName.trim().split(/\s+/)[0], slotLabel: formatSlotLabel(hold.startIso), topicSeed: hold.topic, formUrl }); + email = await loadGuestFirstContact(client, { type: 'podcast', templateId: card.templateDefinitionSnapshot?.guestIntake?.emailTemplateId || 'podcast.first-contact', guestName, startIso: hold.startIso, topic: hold.topic, formUrl, accessCode }); } catch (error) { return json(422, { error: (error as Error).message }); } const recipient = hold.guestRef!.trim().toLowerCase(); // Use the existing Orin Google connection with verified Gmail scopes, also @@ -111,13 +107,13 @@ export async function handlePodcastFirstContactRoutes( } try { - const email = renderPodcastFirstContactEmail({ - guestFirstName: body.guestFirstName as string, - slotLabel: body.slotLabel as string, - formUrl: typeof body.formUrl === 'string' ? body.formUrl : undefined, - topicSeed: typeof body.topicSeed === 'string' ? body.topicSeed : undefined, + const email = await loadGuestFirstContact(client, { + type: 'podcast', templateId: 'podcast.first-contact', guestName: body.guestFirstName as string, + startIso: body.startIso as string, formUrl: body.formUrl as string, + topic: body.topicSeed as string, accessCode: body.accessCode as string, + guestTimezone: body.guestTimezone as string | undefined, }); - return json(200, { ...email, docsPage: PODCAST_DOCS_URL }); + return json(200, email); } catch (error) { return json(400, { error: (error as Error).message }); } diff --git a/backend/src/routes/sponsorDocs.ts b/backend/src/routes/sponsorDocs.ts index bd5d0cccd..25fe66b93 100644 --- a/backend/src/routes/sponsorDocs.ts +++ b/backend/src/routes/sponsorDocs.ts @@ -1,3 +1,5 @@ +import { guestPortalSlots, recordGuestPortalTimeChoice } from '../events/guestIntakePortal'; +import { hydrateGuestEventSlotOptions } from '../sponsorDocs/templates'; import { projectDocumentEvent, readDocumentEventMetadata } from '../sponsorDocs/eventMetadata'; import { randomUUID } from 'node:crypto'; import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; @@ -302,7 +304,13 @@ async function buildDocResponsePayload( const docPath = sponsorDocPathForTemplate(doc.template); const docUrl = `${base}${docPath}/${encodeURIComponent(doc.id)}`; let templateDefinition = getSponsorDocTemplate(doc.template); - if (doc.template === 'podcast') { + let intakeSlots = null; + if (['podcast', 'webinar', 'workshop'].includes(doc.template)) { + try { intakeSlots = await guestPortalSlots(client, doc); } catch { /* Confirmation stays available when alternate availability is unavailable. */ } + } + if (intakeSlots) { + templateDefinition = hydrateGuestEventSlotOptions(templateDefinition, intakeSlots, { label: doc.eventMetadata?.label, startIso: doc.eventMetadata?.startIso, endIso: doc.eventMetadata?.endIso }); + } else if (doc.template === 'podcast') { try { const next = await listNextRecordingSlots(client, 6); const proposedLabel = String(doc.content?.proposedSlotLabel || '').trim() || null; @@ -1018,6 +1026,8 @@ export async function handleSponsorDocRoutes( status: body.status as SponsorDocStatus | undefined, actorKind: 'sponsor', }); + try { if (updated && body.timeChoiceIntent !== undefined) await recordGuestPortalTimeChoice(client, updated, body.timeChoiceIntent); } + catch (error) { return json(422, { error: (error as Error).message, code: 'guest_time_choice_pending' }); } return json(200, { doc: await buildDocResponsePayload(updated!, event, client, { viewerRole: 'sponsor' }), }); diff --git a/backend/src/sponsorDocs/eventMetadata.ts b/backend/src/sponsorDocs/eventMetadata.ts index c4dbcc189..c5263bfa0 100644 --- a/backend/src/sponsorDocs/eventMetadata.ts +++ b/backend/src/sponsorDocs/eventMetadata.ts @@ -1,4 +1,5 @@ import { GetCommand, ScanCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getGuestIntakeJob } from '../events/guestIntakeStore'; import { getCardConsistent } from '../db/cards'; import { getPodcastEpisode } from '../db/podcastEpisodes'; import { TABLE_CALENDAR } from '../db/tableNames'; @@ -13,6 +14,10 @@ export interface DocumentEventMetadata { state: 'tentative' | 'confirmed' | 'released' | 'unscheduled'; label: string; holdId?: string; + startIso?: string; + endIso?: string; + durationMinutes?: number; + guestTimezone?: string; version: number; } @@ -23,6 +28,8 @@ interface StoredEventAuthority { renderValues?: { title?: string; guestNames?: string }; status: 'tentative' | 'confirmed' | 'released' | 'expired' | 'proposed' | 'approved'; startIso?: string; + endIso?: string; + durationMinutes?: number; timeZone?: string; documentUrl?: string; version?: number; @@ -113,6 +120,9 @@ export async function readDocumentEventMetadata( ? 'tentative' : 'unscheduled'; const startIso = hold?.startIso || card?.eventTime?.startIso || (episode as { recordingStartIso?: string | null } | null)?.recordingStartIso || undefined; + const endIso = hold?.endIso || card?.eventTime?.endIso; + const durationMinutes = startIso && endIso ? Math.round((Date.parse(endIso) - Date.parse(startIso)) / 60000) : hold?.durationMinutes; + const intakeJob = card?.guestIntake?.jobId ? await getGuestIntakeJob(client, card.guestIntake.jobId) : null; const timeZone = hold?.timeZone || card?.eventTime?.timeZone || card?.templateDefinitionSnapshot.eventPolicy?.timeZone || 'Europe/Berlin'; let date = card?.anchorDate?.slice(0, 10) || episode?.recordingDate?.slice(0, 10) || null; let time: string | null = null; @@ -136,6 +146,9 @@ export async function readDocumentEventMetadata( state, label, ...(hold ? { holdId: hold.id } : {}), + ...(startIso ? { startIso } : {}), ...(endIso ? { endIso } : {}), + ...(durationMinutes && Number.isFinite(durationMinutes) && durationMinutes > 0 ? { durationMinutes } : {}), + ...(intakeJob?.guestTimezone ? { guestTimezone: intakeJob.guestTimezone } : {}), version: hold?.version || card?.version || episode?.version || 1, }; } diff --git a/backend/src/sponsorDocs/templates.ts b/backend/src/sponsorDocs/templates.ts index e09d9578d..1f29498b4 100644 --- a/backend/src/sponsorDocs/templates.ts +++ b/backend/src/sponsorDocs/templates.ts @@ -1,4 +1,5 @@ import { isOrdinaryRecordingDay } from '../podcast/recordingSlots'; +import { PODCAST_DOCS_URL } from '../podcast/firstContact'; export type SponsorDocTemplateKey = | 'newsletter-main' @@ -14,6 +15,8 @@ export type SponsorDocTemplateKey = export interface SponsorDocFieldOption { value: string; label: string; + startIso?: string; + endIso?: string; } export interface SponsorDocFieldDefinition { @@ -58,6 +61,7 @@ export interface SponsorDocTemplateDefinition { /** Only staff may access this template; external document codes are not valid for it. */ staffOnly?: boolean; guidelinesUrl?: string; + guidelinesLabel?: string; exampleText?: string; sections: SponsorDocSectionDefinition[]; } @@ -76,6 +80,19 @@ export const SPONSOR_DOC_TEMPLATE_KEYS: readonly SponsorDocTemplateKey[] = [ const VISUAL_GUIDELINES_URL = 'https://drive.google.com/file/d/1M_jEzcb1IWJmolx9N7e08gfdbctjcLH2/view?usp=sharing'; +function guestTimezoneField(): SponsorDocFieldDefinition { + return { key: 'guestTimezone', label: 'Your time zone', type: 'text', placeholder: 'For example, Asia/Kolkata', helpText: 'An IANA time zone used to show the event in your local time.' }; +} +function guestEventSchedulingSection(): SponsorDocSectionDefinition { + return { id: 'scheduling', title: 'Event time', description: 'Confirm the proposed time or choose another available slot. You can save your time choice before completing the materials.', fields: [ + { key: 'proposedSlotLabel', derived: 'event-datetime', label: 'Proposed time from the invite', type: 'text', placeholder: 'Provided by the DataTalks.Club team' }, + { key: 'slotChoice', label: 'Event slot', type: 'radio', placeholder: 'Choose one', options: [ + { value: 'confirm_proposed', label: 'The proposed time works for me' }, + { value: 'none_fit', label: 'None of these work; I will reply to the invite email' }, + ] }, guestTimezoneField(), + ] }; +} + export const SPONSOR_DOC_TEMPLATES: Record = { 'newsletter-main': { key: 'newsletter-main', @@ -452,8 +469,9 @@ export const SPONSOR_DOC_TEMPLATES: Record + (slot.window === 'midday' || slot.window === 'evening') && isOrdinaryRecordingDay(slot.date)) : freeSlots; + const options: SponsorDocFieldOption[] = [ + { value: PODCAST_CONFIRM_VALUE, label: proposed?.label ? `Confirm proposed time: ${proposed.label}` : 'The proposed time works for me', + ...(proposed?.startIso ? { startIso: proposed.startIso } : {}), ...(proposed?.endIso ? { endIso: proposed.endIso } : {}) }, + ...ordinary.slice(0, 8).map(slot => ({ value: `slot:${slot.date}:${slot.window}`, + label: slot.label || `${slot.date} ${slot.localTime} Europe/Berlin`, startIso: slot.startIso, endIso: slot.endIso })), + { value: PODCAST_NONE_FIT_VALUE, label: PODCAST_NONE_FIT_LABEL }, + ]; + return { ...template, sections: template.sections.map(section => ({ ...section, + fields: section.fields.map(field => field.key === 'slotChoice' ? { ...field, options } : field), + })) }; +} + /** Staff-readable rendering of a stored slotChoice (never a raw slot:… value). */ export function humanizePodcastSlotChoice(value: string | undefined, proposedLabel?: string): string { const raw = (value || '').trim(); @@ -1379,6 +1440,9 @@ export function renderSponsorDocMarkdownExport(doc: SponsorDocExportInput): stri '### Introduction / Bio', valOrPlaceholder(c.introduction), '', + '### Photo', + /^data:image\//i.test(c.photoUrl || '') ? '[Uploaded photo]' : valOrPlaceholder(c.photoUrl), + '', '## 3) Topics', '', '### Current Focus', @@ -1396,6 +1460,9 @@ export function renderSponsorDocMarkdownExport(doc: SponsorDocExportInput): stri `- **GitHub:** ${valOrPlaceholder(c.githubUrl)}`, `- **Website:** ${valOrPlaceholder(c.websiteUrl)}`, '', + '### Materials to Help Us Prepare', + valOrPlaceholder(c.preparationMaterials), + '', '## 5) Episode Story & Practical Takeaways', '', '### Hook & Pain Point', diff --git a/backend/src/templates/authoredTemplates.ts b/backend/src/templates/authoredTemplates.ts index db1740c0c..d8ffde0f6 100644 --- a/backend/src/templates/authoredTemplates.ts +++ b/backend/src/templates/authoredTemplates.ts @@ -64,6 +64,7 @@ const AUTHORITATIVE_FIELDS = [ 'icon', 'cardTitle', 'eventPolicy', + 'guestIntake', 'tags', 'phases', 'sourceDocIds', diff --git a/backend/src/templates/cardTemplateProjection.ts b/backend/src/templates/cardTemplateProjection.ts index 180b1d9c6..f4564bdaf 100644 --- a/backend/src/templates/cardTemplateProjection.ts +++ b/backend/src/templates/cardTemplateProjection.ts @@ -86,6 +86,7 @@ export function templateCardDefinitionSnapshot( taskRefs: [...new Set(taskRefs)].sort(), episodeStages: episodeStageRefs(template), eventPolicy: template.eventPolicy === undefined ? undefined : copy(template.eventPolicy), + guestIntake: template.guestIntake === undefined ? undefined : copy(template.guestIntake), }); } diff --git a/backend/src/templates/cardTemplateUpdates.ts b/backend/src/templates/cardTemplateUpdates.ts index 82f6c5f1d..4f2a56ebe 100644 --- a/backend/src/templates/cardTemplateUpdates.ts +++ b/backend/src/templates/cardTemplateUpdates.ts @@ -30,6 +30,7 @@ const CARD_FIELDS = [ 'references', 'cardLinkDefinitions', 'eventPolicy', + 'guestIntake', ] as const; const TASK_FIELDS = [ @@ -180,6 +181,7 @@ function taskVersion(task: Task): number { function cardDefinitionValue(card: Card, field: typeof CARD_FIELDS[number]): unknown { if (field === 'eventPolicy') return card.templateDefinitionSnapshot.eventPolicy; + if (field === 'guestIntake') return card.templateDefinitionSnapshot.guestIntake; if (field === 'cardLinkDefinitions') { return (card.cardLinks || []).map(({ name }) => ({ name })); } diff --git a/backend/src/templates/templateEdits.ts b/backend/src/templates/templateEdits.ts index 8984ad62b..f2729f745 100644 --- a/backend/src/templates/templateEdits.ts +++ b/backend/src/templates/templateEdits.ts @@ -36,6 +36,7 @@ export const DEFINITION_FIELDS = [ 'icon', 'cardTitle', 'eventPolicy', + 'guestIntake', 'tags', 'phases', 'sourceDocIds', diff --git a/backend/src/templates/yamlTemplates.ts b/backend/src/templates/yamlTemplates.ts index 4abce3e9d..8b42d3227 100644 --- a/backend/src/templates/yamlTemplates.ts +++ b/backend/src/templates/yamlTemplates.ts @@ -1,4 +1,5 @@ import { eventPolicyIssues } from '../events/policy'; +import { guestIntakePolicyIssues } from '../events/guestIntakePolicy'; /** * Git-authored workflow templates. * @@ -40,6 +41,7 @@ const TEMPLATE_FIELDS = [ ['icon', 'icon'], ['cardTitle', 'card_title'], ['eventPolicy', 'event_policy'], + ['guestIntake', 'guest_intake'], ['tags', 'tags'], ['sourceDocIds', 'source_document_ids'], ] as const; @@ -275,6 +277,7 @@ export function validateAuthoredTemplate( if (doc.event_policy !== undefined) { for (const issue of eventPolicyIssues(doc.event_policy)) fail(issue); } + for (const issue of guestIntakePolicyIssues(doc.guest_intake, String(doc.type))) fail(issue); if (doc.card_title !== undefined) { for (const message of cardTitlePatternIssues(String(doc.type), doc.card_title)) fail(message); diff --git a/backend/src/types.ts b/backend/src/types.ts index 9088a80a0..1eb3f1d58 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -394,6 +394,7 @@ export interface CardCalendarInvite { } export interface Card { + guestIntake?: { jobId: string; taskId: string; status: import('./events/guestIntakeStore').GuestIntakeStatus; skipEmail: boolean; recipient?: string; messageId?: string; sentAt?: string; startIso?: string; error?: string }; id: string; /** Monotonic optimistic-concurrency token. */ version: number; @@ -611,6 +612,7 @@ export interface TaskDefinition { /** Definition-owned Card defaults captured when a Template is applied. */ export interface TemplateCardDefinitionSnapshot { + guestIntake?: import('./events/guestIntakePolicy').GuestIntakePolicy; eventPolicy?: EventPolicy; tags: string[]; sourceDocIds: string[]; @@ -670,6 +672,7 @@ export interface TemplateTaskDefinitionSnapshot { } export interface Template { + guestIntake?: import('./events/guestIntakePolicy').GuestIntakePolicy; eventPolicy?: EventPolicy; id: string; /** Authored identity and schema revision, separate from runtime id/version. */ diff --git a/backend/tests/calendar-holds.test.ts b/backend/tests/calendar-holds.test.ts index bab97610f..835cceebc 100644 --- a/backend/tests/calendar-holds.test.ts +++ b/backend/tests/calendar-holds.test.ts @@ -93,7 +93,7 @@ describe("calendar holds (web parity + tentative proposal + 7d TTL)", () => { assert.equal(hold.durationMinutes, 60); assert.equal(hold.window, "evening"); assert.equal(hold.timeZone, "Europe/Berlin"); - assert.equal(hold.googleTitle, "(Proposal) GraphRAG workshop"); + assert.equal(hold.googleTitle, "[Proposed] GraphRAG workshop"); // Evening 60-min (podcast/webinar) starts 17:00 Berlin; 2026-10-26 is CET (+01:00). assert.equal(hold.startIso, "2026-10-26T16:00:00.000Z"); assert.equal(hold.endIso, "2026-10-26T17:00:00.000Z"); @@ -134,7 +134,7 @@ describe("calendar holds (web parity + tentative proposal + 7d TTL)", () => { assert.ok(holdItem, "hold is projected as an overlay"); assert.equal(holdItem.provider, "calendar-holds"); assert.equal(holdItem.isAvailableSlot, false); - assert.match(holdItem.label, /\(Proposal\) Lunch recording/); + assert.match(holdItem.label, /\[Proposed\] Lunch recording/); assert.ok(overlays.sources["calendar-holds"]); // A suppressed owner slot is never offered while the hold is active. const withSlot = suppressHeldSlots( @@ -384,8 +384,8 @@ describe("calendar holds (web parity + tentative proposal + 7d TTL)", () => { const midday90 = holdInterval("2026-10-26", "midday", 90); assert.equal(midday90.startLocal, "12:00"); assert.equal(midday90.endLocal, "13:30"); - assert.equal(proposalTitle("Talk"), "(Proposal) Talk"); - assert.equal(proposalTitle("(Proposal) Talk"), "(Proposal) Talk"); + assert.equal(proposalTitle("Talk"), "[Proposed] Talk"); + assert.equal(proposalTitle("(Proposal) Talk"), "[Proposed] Talk"); assert.equal(confirmedTitle("(Proposal) Talk"), "Talk"); assert.ok(isHoldActive({ status: "tentative" })); assert.ok(!isHoldActive({ status: "expired" })); diff --git a/backend/tests/event-guest-contact.test.ts b/backend/tests/event-guest-contact.test.ts new file mode 100644 index 000000000..7419033a1 --- /dev/null +++ b/backend/tests/event-guest-contact.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { before, after, test } from 'node:test'; +import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getClient } from '../src/db/client'; +import { startLocal, createTables, stopLocal } from '../scripts/local-dynamodb'; +import { TABLE_SPONSOR_CRM } from '../src/db/tableNames'; +import { GuestContactInputError, resolveGuestContact } from '../src/events/guestContact'; + +let client: DynamoDBDocumentClient; +before(async () => { client = await getClient(await startLocal()); await createTables(client); }); +after(stopLocal); +async function store(id: string, value: Record, kind = 'contact') { + const key = `${kind.toUpperCase()}#${id}`; + await client.send(new PutCommand({ TableName: TABLE_SPONSOR_CRM, Item: { + PK: key, SK: key, id, version: 1, createdAt: '2030-01-01', updatedAt: '2030-01-01', ...value, + } })); +} + +test('resolves the private directory person to structured saved email without a scan', async () => { + await store('saved-person', { name: 'Synthetic Guest', emails: ['Guest@Example.test'] }); + assert.deepEqual(await resolveGuestContact(client, { guestPersonId: 'saved-person' }), { + name: 'Synthetic Guest', email: 'guest@example.test', reference: { kind: 'contact', id: 'saved-person' }, + }); +}); + +test('a known guest without structured email remains contact pending, never mined from notes', async () => { + await store('no-email', { name: 'Known Guest', notes: 'email guessed@example.test', linkedin: 'https://example.test/profile' }); + assert.deepEqual(await resolveGuestContact(client, { guestReference: { kind: 'contact', id: 'no-email' } }), { + name: 'Known Guest', reference: { kind: 'contact', id: 'no-email' }, + }); +}); + +test('accepts explicit email alone or an explicit replacement on a saved contact', async () => { + assert.deepEqual(await resolveGuestContact(client, { guestEmail: 'New@Example.test' }), { email: 'new@example.test' }); + await store('overridden', { name: 'Saved Name', emails: ['old@example.test'] }); + assert.deepEqual(await resolveGuestContact(client, { guestEmail: 'new@example.test', guestContactId: 'overridden', guestName: 'Given Name' }), { + email: 'new@example.test', name: 'Given Name', reference: { kind: 'contact', id: 'overridden' }, + }); +}); + +test('rejects unknown, conflicting, inactive and do-not-contact references', async () => { + await assert.rejects(resolveGuestContact(client, { guestPersonId: 'missing' }), error => error instanceof GuestContactInputError && error.statusCode === 404); + await assert.rejects(resolveGuestContact(client, { guestPersonId: 'one', guestContactId: 'two' }), /different/); + await store('inactive', { name: 'Inactive Guest', active: false, emails: ['inactive@example.test'] }); + await assert.rejects(resolveGuestContact(client, { guestContactId: 'inactive' }), /inactive/); + await store('blocked-company', { doNotContact: true }, 'organization'); + await store('blocked', { name: 'Blocked Guest', organizationId: 'blocked-company', emails: ['blocked@example.test'] }); + await assert.rejects(resolveGuestContact(client, { guestContactId: 'blocked', guestEmail: 'explicit@example.test' }), /do not contact/); +}); diff --git a/backend/tests/event-service.test.ts b/backend/tests/event-service.test.ts index f80d2287a..07614f191 100644 --- a/backend/tests/event-service.test.ts +++ b/backend/tests/event-service.test.ts @@ -417,7 +417,7 @@ describe('Unified Event Lifecycle Service & API (AC3, AC4, AC5, AC7, AC8, AC9)', const evId = proposed.event.googleEventIds[cal]; assert.ok(evId, `Google event created on ${cal}`); const gEv = googleMock.calendars.get(cal)!.get(evId); - assert.equal(gEv.summary, '(Proposal) Podcast: Ian Whitestone - Agentic Data Stack'); + assert.equal(gEv.summary, '[Proposed] Podcast: Ian Whitestone - Agentic Data Stack'); assert.equal(gEv.location, ''); } // Proposal transition email sent to guest @@ -513,7 +513,7 @@ describe('Unified Event Lifecycle Service & API (AC3, AC4, AC5, AC7, AC8, AC9)', for (const cal of [HOLD_ORGANIZER, ...DEFAULT_HOLD_GOOGLE_CALENDARS]) { const evId = rescheduled.event.googleEventIds[cal]; const gEv = googleMock.calendars.get(cal)!.get(evId); - assert.equal(gEv.summary, '(Proposal) Podcast: Ian Whitestone - Agentic Data Stack'); + assert.equal(gEv.summary, '[Proposed] Podcast: Ian Whitestone - Agentic Data Stack'); assert.equal(gEv.location, ''); } // Card, Episode, and linked Docs all reflect 2026-12-01 12:30 (tentative) @@ -595,7 +595,7 @@ describe('Unified Event Lifecycle Service & API (AC3, AC4, AC5, AC7, AC8, AC9)', const orgId = prop.event.googleEventIds[HOLD_ORGANIZER]; assert.equal( googleMock.getOrganizerEvent(orgId).summary, - '(Proposal) Webinar: Streaming Lakehouse in Practice', + '[Proposed] Webinar: Streaming Lakehouse in Practice', ); // 1. Guest declines first -> recorded and surfaced, NEVER auto-releases diff --git a/backend/tests/guest-event-form.test.ts b/backend/tests/guest-event-form.test.ts new file mode 100644 index 000000000..e23d2ce0f --- /dev/null +++ b/backend/tests/guest-event-form.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getSponsorDocTemplate, hydrateGuestEventSlotOptions } from '../src/sponsorDocs/templates'; +test('all three guest forms collect scheduling, optional photo/materials and time zone in their existing form', () => { + for (const type of ['podcast', 'webinar', 'workshop'] as const) { + const template = getSponsorDocTemplate(type); + assert.equal(template.metadataSource, 'card-event'); + const fields = template.sections.flatMap(section => section.fields); + for (const key of ['slotChoice', 'proposedSlotLabel', 'guestTimezone', 'photoUrl', 'preparationMaterials']) assert.ok(fields.some(field => field.key === key), `${type}: ${key}`); + assert.equal(fields.find(field => field.key === 'photoUrl')!.allowVisualUpload, true); + assert.equal(fields.find(field => field.key === 'photoUrl')!.maxCharsHint, 300000); + if (type !== 'podcast') for (const key of ['talkTitle', 'outline', 'bio']) assert.equal(fields.find(field => field.key === key)!.required, true); + } +}); +test('general hydration preserves canonical instants/values and retains podcast day policy only for podcast', () => { + const slots = [{ date: '2030-12-04', window: 'evening', localTime: '17:00', label: 'Scheduler label', startIso: '2030-12-04T16:00:00Z', endIso: '2030-12-04T17:00:00Z' }]; + for (const type of ['podcast', 'webinar', 'workshop'] as const) { + const original = getSponsorDocTemplate(type); + const form = hydrateGuestEventSlotOptions(original, slots, { label: 'Proposed label', startIso: '2030-12-02T16:00:00Z', endIso: '2030-12-02T17:00:00Z' }); + const options = form.sections.flatMap(section => section.fields).find(field => field.key === 'slotChoice')!.options!; + assert.equal(options[0].startIso, '2030-12-02T16:00:00Z'); + assert.equal(options[0].value, 'confirm_proposed'); + assert.equal(options.length, type === 'podcast' ? 2 : 3); + if (type !== 'podcast') assert.deepEqual(options[1], { value: 'slot:2030-12-04:evening', label: 'Scheduler label', startIso: slots[0].startIso, endIso: slots[0].endIso }); + assert.ok(!original.sections.flatMap(section => section.fields).find(field => field.key === 'slotChoice')!.options!.some(option => option.startIso), 'no mutation of static definition'); + } +}); diff --git a/backend/tests/guest-intake-allocation.test.ts b/backend/tests/guest-intake-allocation.test.ts new file mode 100644 index 000000000..17b037a04 --- /dev/null +++ b/backend/tests/guest-intake-allocation.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getClient } from '../src/db/client'; +import { startLocal, createTables, stopLocal } from '../scripts/local-dynamodb'; +import { claimGuestSlotAllocator } from '../src/events/guestIntakeWorker'; +import { prepareGuestIntake } from '../src/events/guestIntakeCreate'; +import { saveGuestIntakeJob } from '../src/events/guestIntakeStore'; +import { createCard } from '../src/db/cards'; +import { TABLE_ARTIFACTS } from '../src/db/tableNames'; +import { templateLink } from './helpers/cardTemplate'; +import type { Template } from '../src/types'; +let db: DynamoDBDocumentClient; +before(async () => { db = await getClient(await startLocal()); await createTables(db); }); +after(stopLocal); +const template = { id: 'synthetic-template', type: 'podcast', name: 'Podcast', guestIntake: { enabled: true, emailTemplateId: 'podcast.first-contact', formTemplate: 'podcast', followUpDays: 7 } } as Template; + +test('two concurrent guest Card jobs cannot enter slot read/reservation together', async () => { + const results = await Promise.all(['job-one', 'job-two'].map(id => claimGuestSlotAllocator(db, id))); + assert.equal(results.filter(Boolean).length, 1); + const winner = results[0] ? 'job-one' : 'job-two'; + const loser = results[0] ? 'job-two' : 'job-one'; + assert.equal(await claimGuestSlotAllocator(db, loser), false, 'loser cannot select the same available slot while winner reserves it'); + await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: { PK: 'GUESTINTAKEALLOCATOR', SK: 'GUESTINTAKEALLOCATOR' }, UpdateExpression: 'SET leaseUntil = :zero', ConditionExpression: 'ownerId = :winner', ExpressionAttributeValues: { ':zero': 0, ':winner': winner } })); + assert.equal(await claimGuestSlotAllocator(db, loser), true, 'next job may refresh authoritative availability only after prior durable proposal'); +}); + +test('email alone creates enabled guest intake; unapproved and disabled types do not', async () => { + const enabled = await prepareGuestIntake(db, 'synthetic-actor', template, { guestEmail: 'guest@example.test', creationKey: 'email-alone' }); + assert.equal(enabled!.job.status, 'queued'); + assert.equal(enabled!.job.guestEmail, 'guest@example.test'); + assert.equal(enabled!.job.requestedDate, undefined); + assert.equal(enabled!.job.guestName, undefined); + assert.equal(await prepareGuestIntake(db, 'synthetic-actor', { ...template, type: 'office-hours' }, { guestEmail: 'guest@example.test' }), null); + assert.equal(await prepareGuestIntake(db, 'synthetic-actor', { ...template, guestIntake: { ...template.guestIntake!, enabled: false } }, { guestEmail: 'guest@example.test' }), null); +}); + +test('same-key changed requests fail both sequential lookup and concurrent loser replay', async () => { + const firstBody = { guestEmail: 'first@example.test', creationKey: 'same-key' }; + const secondBody = { guestEmail: 'second@example.test', creationKey: 'same-key' }; + const [first, second] = await Promise.all([prepareGuestIntake(db, 'synthetic-actor', template, firstBody), prepareGuestIntake(db, 'synthetic-actor', template, secondBody)]); + assert.equal(first!.job.id, second!.job.id); + const card = await createCard(db, { ...templateLink(), title: 'Synthetic winner', anchorDate: '2030-12-02' }); + second!.job.cardId = card.id; + await saveGuestIntakeJob(db, second!.job); + await assert.rejects(prepareGuestIntake(db, 'synthetic-actor', template, firstBody), /different Card request/); + await assert.rejects(first!.replay(), /different Card request/); + const replay = await second!.replay(); + assert.equal(replay!.card.id, card.id); +}); + +test('routine invitation Task uses the configured operational assignee rather than the creator', async () => { + const configured = { ...template, guestIntake: { ...template.guestIntake!, firstContactTaskRefs: ['first-contact'] }, taskDefinitions: [{ refId: 'first-contact', description: 'First contact', assigneeId: 'synthetic-ops-owner' }] } as Template; + const intent = (await prepareGuestIntake(db, 'synthetic-human-creator', configured, { guestEmail: 'guest@example.test', creationKey: 'ownership' }))!; + assert.equal(intent.job.actorId, 'synthetic-human-creator'); + assert.equal(intent.tasks({ id: 'synthetic-card' } as any)[0].assigneeId, 'synthetic-ops-owner'); +}); diff --git a/backend/tests/guest-intake-worker.test.ts b/backend/tests/guest-intake-worker.test.ts new file mode 100644 index 000000000..d47c0417a --- /dev/null +++ b/backend/tests/guest-intake-worker.test.ts @@ -0,0 +1,234 @@ +import { rescheduleEvent, approveEvent } from '../src/events/service'; +import { configureTaskChecklistStore } from '../src/taskChecklist'; +import { createTaskChecklistFixture } from './helpers/task-checklist-fixture'; +import { guestPortalSlots, recordGuestPortalTimeChoice, processGuestChoice } from '../src/events/guestIntakePortal'; +import { before, after, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getClient } from '../src/db/client'; +import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; +import { createTemplate } from '../src/db/templates'; +import { getCardConsistent } from '../src/db/cards'; +import { getTaskConsistent, listTasksByCardConsistent } from '../src/db/tasks'; +import { getGuestIntakeJob } from '../src/events/guestIntakeStore'; +import { processGuestIntake, pollGuestIntakeAcceptances } from '../src/events/guestIntakeWorker'; +import { getEventRecord } from '../src/events/store'; +import { getSponsorDocWithAuthMaterial, updateSponsorDoc, decryptSponsorDocAccessCode } from '../src/db/sponsorDocs'; +import { setCalendarHoldGoogleDepsForTests } from '../src/db/calendarHolds'; +import { setOwnerCalendarProviderClientsForTests } from '../src/calendar/ownerCalendarOverlay'; +import { saveCalendarOverlaySettings } from '../src/db/calendar'; +import { setSponsorDocEmailDepsForTests } from '../src/sponsorDocs/emailDelivery'; +import { TABLE_USERS, TABLE_ARTIFACTS } from '../src/db/tableNames'; +import { policy } from './helpers/eventPolicy'; +import { createMockGoogleCalendar } from './helpers/guest-intake-google'; +import { HOLD_ORGANIZER } from '../src/calendar/holdGoogleSync'; +import { handler } from '../src/handler'; +let db: DynamoDBDocumentClient; +let google = createMockGoogleCalendar(); +const checklist = createTaskChecklistFixture(); +let mails = 0; +let ownerEvents: unknown[] = []; +let failMail = false; +let onMailSend: (() => Promise) | undefined; +const invoke = (method: string, path: string, body?: unknown) => handler({ httpMethod: method, path, headers: { 'x-user-id': 'synthetic-actor' }, body: body ? JSON.stringify(body) : null, queryStringParameters: null }); +before(async () => { + configureTaskChecklistStore(checklist.store); + db = await getClient(await startLocal()); await createTables(db); + await saveCalendarOverlaySettings(db, { connectionId: 'synthetic-google' }, 'synthetic-actor'); + setCalendarHoldGoogleDepsForTests({ credential: 'synthetic', connectionId: 'synthetic-google', fetchImpl: google.fetchImpl }); + setOwnerCalendarProviderClientsForTests({ readSecret: async () => 'synthetic', fetch: async (url) => new Response(JSON.stringify(String(url).endsWith('/api/agent/token') ? { access_token: 'synthetic' } : { items: ownerEvents }), { status: 200 }) }); + setSponsorDocEmailDepsForTests({ credential: 'synthetic', fetchImpl: async (url) => { + if (String(url).endsWith('/messages/send')) { mails++; if (onMailSend) { const callback = onMailSend; onMailSend = undefined; await callback(); } if (failMail) throw new Error('synthetic ambiguous timeout'); return new Response(JSON.stringify({ id: `synthetic-mail-${mails}` }), { status: 200 }); } + if (String(url).includes('userinfo')) return new Response(JSON.stringify({ email: 'orin@datatalks.club' }), { status: 200 }); + return new Response(JSON.stringify({ access_token: 'synthetic', provider_account_id: 'orin@datatalks.club', scopes: ['https://www.googleapis.com/auth/gmail.send'] }), { status: 200 }); + } }); + await db.send(new PutCommand({ TableName: TABLE_USERS, Item: { PK: 'SETTINGS#text-templates', SK: 'SETTINGS#text-templates', version: 1, templates: ['podcast','webinar','workshop'].map(type => ({ id: `${type}.first-contact`, body: `Subject: DataTalks.Club ${type}\n\n\nJoin us. Would at work?\n\nAccess code: `, title: type, updatedAt: '2030-01-01' })) } })); +}); +after(async () => { configureTaskChecklistStore(null); checklist.cleanup(); setCalendarHoldGoogleDepsForTests({}); setSponsorDocEmailDepsForTests({}); setOwnerCalendarProviderClientsForTests({}); await stopLocal(); }); +async function card(type: 'podcast'|'webinar'|'workshop', extra: Record = {}) { + const eventPolicy = policy(type); + eventPolicy.zoom = { enabled: false }; eventPolicy.guests.leadMinutes = 5; + for (const notification of Object.values(eventPolicy.notifications)) notification.emails = []; + const template = await createTemplate(db, { name: type, type, eventPolicy, guestIntake: { enabled: true, emailTemplateId: `${type}.first-contact`, formTemplate: type, followUpDays: 7, ...(type === 'podcast' ? { weekdays: [1,2], firstContactTaskRefs: ['obtain-speaker-email','create-proposed-calendar-invite'] } : {}) }, taskDefinitions: type === 'podcast' ? [ + { refId: 'obtain-speaker-email', description: 'Obtain guest email', offsetDays: 0, requiredLinkName: 'Guest email', proofRequirement: { type: 'url', required: true } }, + { refId: 'create-proposed-calendar-invite', description: 'Create proposed invite', offsetDays: 0, dependsOn: ['obtain-speaker-email'], proofRequirement: { type: 'comment', required: true } }, + ] : [] }); + const body = { templateId: template.id, guestEmail: 'synthetic-guest@example.test', creationKey: crypto.randomUUID(), ...extra }; + const result = await invoke('POST', '/api/cards', body); + assert.equal(result.statusCode, 201, result.body); + return { card: JSON.parse(result.body).card, body }; +} +test('creation queues a durable job with email alone; one worker sends both channels and retries do not duplicate', async () => { + const created = await card('podcast', { guestTimezone: 'America/New_York' }); + const beforeMails = mails; + assert.equal(created.card.guestIntake.status, 'queued'); + assert.equal(mails, beforeMails); + const replay = await invoke('POST','/api/cards',created.body); + assert.equal(JSON.parse(replay.body).card.id, created.card.id); + await handler({ Records: [{ eventSource: 'aws:dynamodb', eventName: 'INSERT', dynamodb: { NewImage: { guestIntake: { M: { jobId: { S: created.card.guestIntake.jobId } } } } } }] }); + const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + assert.equal(job.status, 'sent'); assert.equal(mails, beforeMails + 1); + const event = (await getEventRecord(db, job.holdId!))!; + assert.equal(event.durationMinutes, 60); + assert.equal(event.operations[0].desired.startIso, new Date(Date.parse(event.startIso)-5*60000).toISOString()); assert.match(event.operations[0].desired.summary, /^\[Proposed\] /); + assert.ok(event.operations[0].desired.attendees.includes(created.body.guestEmail)); + assert.equal(event.operations[0].desired.sendUpdates, 'all'); + assert.match(event.operations[0].desired.description, /Access code:/); assert.match(event.operations[0].desired.description, /America\/New_York/); + const doc = (await getSponsorDocWithAuthMaterial(db, job.docId!))!.doc; + assert.equal(doc.status, 'draft'); assert.equal(doc.content.bio || '', ''); + assert.equal((await getTaskConsistent(db, job.taskId))!.status, 'waiting'); + const completedRefs = (await listTasksByCardConsistent(db, job.cardId)).filter(t => t.templateTaskRef); + assert.equal(completedRefs.length, 2); + assert.ok(completedRefs.every(t => t.status === 'done' && t.completedAt && t.link?.includes(job.messageId!))); + assert.ok((await getCardConsistent(db, job.cardId))!.cardLinks!.some(l => l.name === 'Guest email' && l.url.includes(job.messageId!))); + await processGuestIntake(db, job.id); assert.equal(mails, beforeMails + 1); + assert.equal((await getCardConsistent(db, job.cardId))!.eventTime!.eventKey, event.id); + const ids = { ...event.googleEventIds }; + google.setAttendeeResponse(ids[HOLD_ORGANIZER], created.body.guestEmail, 'accepted'); + await pollGuestIntakeAcceptances(db); + const accepted = (await getEventRecord(db, event.id))!; + assert.equal(accepted.status, 'approved'); + const acceptanceTask = (await getTaskConsistent(db, job.taskId))!; + assert.equal(acceptanceTask.status, 'done'); assert.equal(acceptanceTask.followUpAt ?? null, null); assert.deepEqual(accepted.googleEventIds, ids); + assert.ok(!accepted.operations.at(-1)!.desired.summary.startsWith('[Proposed]')); + const moved = await rescheduleEvent(db, job.cardId, { date: '2030-12-10', time: '17:00', actor: 'synthetic-staff', operationKey: 'staff-move-requires-fresh-rsvp' }); + assert.equal(moved.event.status, 'proposed'); + assert.deepEqual(moved.event.googleEventIds, ids); + await pollGuestIntakeAcceptances(db); + assert.equal((await getEventRecord(db, event.id))!.status, 'proposed', 'old accepted response must reset; staff move is not new guest acceptance'); +}); +test('skip-email creates one private workshop event and one blank form, without email or calendar notification', async () => { + const created = await card('workshop', { skipEmail: true, anchorDate: '2030-12-02', time: '10:00', timeZone: 'America/New_York' }); + const beforeMails = mails; const beforeCalendarCalls = google.calls.length; + await processGuestIntake(db, created.card.guestIntake.jobId); + assert.ok(!google.calls.slice(beforeCalendarCalls).some(call => new URL(call.url).searchParams.get('sendUpdates') === 'all')); + const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + assert.equal(job.status, 'skipped'); assert.equal(mails, beforeMails); + const event = (await getEventRecord(db, job.holdId!))!; + assert.equal(event.startIso, '2030-12-02T15:00:00.000Z'); assert.equal(event.durationMinutes, 90); + assert.deepEqual(event.operations[0].desired.attendees, []); assert.equal(event.operations[0].desired.sendUpdates, 'none'); + assert.equal((await getSponsorDocWithAuthMaterial(db, job.docId!))!.doc.template, 'workshop'); + await processGuestIntake(db, job.id); assert.equal(mails, beforeMails); +}); +test('an ambiguous Gmail timeout preserves one attempted send and blocks automatic resend', async () => { + const created = await card('webinar'); const beforeMails = mails; failMail = true; + await assert.rejects(processGuestIntake(db, created.card.guestIntake.jobId), /pending/); + failMail = false; + const failed = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + assert.equal(failed.status, 'failed'); assert.equal(failed.retryable, false); + await processGuestIntake(db, failed.id); assert.equal(mails, beforeMails + 1); +}); + +test('durable failed Calendar stage resumes automatically with the same event, document and one email', async () => { + const created = await card('webinar'); const beforeMails = mails; + let failOnce = true; + setCalendarHoldGoogleDepsForTests({ credential: 'synthetic', connectionId: 'synthetic-google', fetchImpl: async (url, init) => { + if (failOnce && init?.method === 'PATCH') { failOnce = false; return new Response('{}', { status: 503 }); } + return google.fetchImpl(url, init); + } }); + await assert.rejects(processGuestIntake(db, created.card.guestIntake.jobId), /Calendar invitation is pending/); + const failed = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + assert.equal(failed.retryable, true); assert.equal(mails, beforeMails); + const firstEvent = (await getEventRecord(db, `hold_${created.card.id}`))!; + const resumed = await handler({ source: 'aws.events', detail: { dataopsAction: 'guest-event-intake-resume' } }); + assert.equal(resumed.statusCode, 200); + const done = (await getGuestIntakeJob(db, failed.id))!; + assert.equal(done.status, 'sent'); assert.equal(done.docId, failed.docId); assert.equal(done.holdId, firstEvent.id); + assert.equal(mails, beforeMails + 1); + setCalendarHoldGoogleDepsForTests({ credential: 'synthetic', connectionId: 'synthetic-google', fetchImpl: google.fetchImpl }); +}); +test('guest alternative time confirms immediately on the same event without completing bio or outline', async () => { + const created = await card('webinar'); + await processGuestIntake(db, created.card.guestIntake.jobId); + const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + const before = (await getEventRecord(db, job.holdId!))!; + const loaded = (await getSponsorDocWithAuthMaterial(db, job.docId!))!; + const slots = await guestPortalSlots(db, loaded.doc); + const chosen = slots!.find(s => s.startIso !== before.startIso)!; + assert.ok(chosen); + const chosenDoc = (await updateSponsorDoc(db, loaded.doc.id, { content: { slotChoice: `slot:${chosen.date}:${chosen.window}`, guestTimezone: 'America/New_York' }, actorKind: 'sponsor' }))!; + await recordGuestPortalTimeChoice(db, chosenDoc, { choice: chosenDoc.content.slotChoice, proposalVersion: before.version, proposalStartIso: before.startIso }); + const after = (await getEventRecord(db, before.id))!; + assert.equal(after.status, 'approved'); assert.equal(after.startIso, chosen.startIso); + assert.equal((await getTaskConsistent(db, job.taskId))!.status, 'done'); + assert.equal((await getTaskConsistent(db, job.taskId))!.followUpAt ?? null, null); + assert.deepEqual(after.googleEventIds, before.googleEventIds); + assert.ok(!after.operations.at(-1)!.desired.summary.startsWith('[Proposed]')); + assert.equal(loaded.doc.content.speakerBio || '', ''); +}); + +test('a skipped invitation preview is read-only; explicit first-send reuses its private event and form', async () => { + const created = await card('webinar', { skipEmail: true }); + await processGuestIntake(db, created.card.guestIntake.jobId); + const skipped = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + const ids = { ...(await getEventRecord(db, skipped.holdId!))!.googleEventIds }; + const beforeMails = mails; + const preview = await handler({ httpMethod: 'GET', path: `/api/cards/${created.card.id}/guest-intake`, headers: { 'x-user-id': 'synthetic-actor' }, queryStringParameters: { preview: '1' }, body: null }); + assert.equal(preview.statusCode, 200, preview.body); + assert.equal(JSON.parse(preview.body).preview, true); assert.equal(mails, beforeMails); + const queued = await invoke('POST', `/api/cards/${created.card.id}/guest-intake/send`, {}); + assert.equal(queued.statusCode, 202, queued.body); + await processGuestIntake(db, skipped.id); + const sent = (await getGuestIntakeJob(db, skipped.id))!; + assert.equal(sent.status, 'sent'); assert.equal(sent.docId, skipped.docId); assert.equal(sent.holdId, skipped.holdId); + assert.deepEqual((await getEventRecord(db, sent.holdId!))!.googleEventIds, ids); + assert.equal(mails, beforeMails + 1); + assert.equal((await invoke('POST', `/api/cards/${created.card.id}/guest-intake/send`, {})).statusCode, 409); +}); + +test('acceptance racing Gmail receipt bookkeeping leaves no waiting follow-up', async () => { + const created = await card('webinar'); + onMailSend = async () => { await approveEvent(db, created.card.id, { actor: 'synthetic-guest-accept', operationKey: 'accept-during-mail-send' }); }; + await processGuestIntake(db, created.card.guestIntake.jobId); + const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + const task = (await getTaskConsistent(db, job.taskId))!; + assert.equal(job.status, 'sent'); assert.equal(task.status, 'done'); assert.equal(task.followUpAt ?? null, null); +}); +test('ordinary guest bio saves do not approve staff moves; stale explicit confirmations are rejected', async () => { + const created = await card('webinar'); await processGuestIntake(db, created.card.guestIntake.jobId); + const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + const original = (await getEventRecord(db, job.holdId!))!; + const loaded = (await getSponsorDocWithAuthMaterial(db, job.docId!))!; + const code = decryptSponsorDocAccessCode(loaded.doc.id, loaded.accessCodeCiphertext)!; + const guestPut = (body: unknown) => handler({ httpMethod: 'PUT', path: `/api/sponsor-docs/${loaded.doc.id}`, headers: { 'x-sponsor-doc-anonymous': 'true' }, queryStringParameters: { code }, body: JSON.stringify(body) }); + const first = await guestPut({ content: { slotChoice: 'confirm_proposed' }, status: 'draft', timeChoiceIntent: { choice: 'confirm_proposed', proposalVersion: original.version, proposalStartIso: original.startIso } }); + assert.equal(first.statusCode, 200, first.body); + const moved = await rescheduleEvent(db, job.cardId, { date: '2030-12-17', time: '17:00', actor: 'synthetic-staff', operationKey: 'staff-move-after-guest-confirm' }); + const bio = await guestPut({ content: { slotChoice: 'confirm_proposed', bio: 'Updated biography' }, status: 'draft' }); + assert.equal(bio.statusCode, 200, bio.body); + assert.equal((await getEventRecord(db, job.holdId!))!.status, 'proposed'); + assert.equal((await getEventRecord(db, job.holdId!))!.startIso, moved.event.startIso); + const stale = await guestPut({ content: { slotChoice: 'confirm_proposed' }, status: 'draft', timeChoiceIntent: { choice: 'confirm_proposed', proposalVersion: original.version, proposalStartIso: original.startIso } }); + assert.equal(stale.statusCode, 422, stale.body); + assert.equal((await getEventRecord(db, job.holdId!))!.status, 'proposed'); +}); + +test('a stale scanned guest choice cannot update Calendar after a newer intent or changed selection', async () => { + const created = await card('webinar'); await processGuestIntake(db, created.card.guestIntake.jobId); + const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + const event = (await getEventRecord(db, job.holdId!))!; + const loaded = (await getSponsorDocWithAuthMaterial(db, job.docId!))!; + const first = (await updateSponsorDoc(db, job.docId!, { content: { slotChoice: 'confirm_proposed' }, actorKind: 'sponsor' }))!; + const key = { PK: `GUESTCHOICE#${job.docId}`, SK: `GUESTCHOICE#${job.docId}` }; + const scanned = { ...key, docId: job.docId, choice: 'confirm_proposed', intent: { choice: 'confirm_proposed', proposalVersion: event.version, proposalStartIso: event.startIso }, version: first.version, status: 'pending' }; + await db.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: { ...scanned, version: first.version+1, choice: 'none_fit', status: 'cancelled' } })); + const calls = google.calls.length; + await processGuestChoice(db, scanned); + assert.equal(google.calls.length, calls, 'stale scan must fail its version claim before provider calls'); + await db.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: scanned })); + await updateSponsorDoc(db, loaded.doc.id, { content: { slotChoice: 'none_fit' }, actorKind: 'sponsor' }); + await processGuestChoice(db, scanned); + assert.equal(google.calls.length, calls, 'changed current guest choice must cancel the old action before provider calls'); + assert.equal((await getEventRecord(db, event.id))!.status, 'proposed'); +}); + +test('explicit recording time is not moved when its five-minute join lead overlaps a prior booking', async () => { + ownerEvents = [{ id: 'synthetic-prior-booking', summary: 'Client meeting', start: { dateTime: '2030-12-18T15:00:00Z' }, end: { dateTime: '2030-12-18T16:00:00Z' } }]; + const created = await card('webinar', { anchorDate: '2030-12-18', time: '17:00', timeZone: 'Europe/Berlin' }); + const beforeMails = mails; + await assert.rejects(processGuestIntake(db, created.card.guestIntake.jobId), /requested date\/time is unavailable/); + const failed = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + assert.equal(failed.status, 'failed'); assert.equal(failed.requestedTime, '17:00'); assert.equal(failed.selectedTime, undefined); + assert.equal(await getEventRecord(db, `hold_${created.card.id}`), null); assert.equal(mails, beforeMails); + ownerEvents = []; +}); diff --git a/backend/tests/helpers/guest-intake-google.ts b/backend/tests/helpers/guest-intake-google.ts new file mode 100644 index 000000000..81d021d8e --- /dev/null +++ b/backend/tests/helpers/guest-intake-google.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { HOLD_ORGANIZER } from '../../src/calendar/holdGoogleSync'; +import { DEFAULT_HOLD_GOOGLE_CALENDARS } from '../../src/calendar/dapierBroker'; +export function createMockGoogleCalendar() { + const calendars = new Map>(); + const ensureCal = (calId: string) => { + if (!calendars.has(calId)) calendars.set(calId, new Map()); + return calendars.get(calId)!; + }; + ensureCal(HOLD_ORGANIZER); + for (const cal of DEFAULT_HOLD_GOOGLE_CALENDARS) ensureCal(cal); + + const calls: Array<{ method: string; url: string; body?: any }> = []; + let seq = 1; + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = String(input); + const method = (init?.method || 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ method, url: urlStr, body }); + + if (urlStr.endsWith('/api/agent/token')) { + return new Response(JSON.stringify({ access_token: 'mock-google-token' }), { status: 200 }); + } + if (urlStr.endsWith('/calendars/primary')) { + return new Response(JSON.stringify({ id: HOLD_ORGANIZER }), { status: 200 }); + } + + const parsed = new URL(urlStr); + const calMatch = parsed.pathname.match(/\/calendar\/v3\/calendars\/([^/]+)\/events(?:\/([^/]+))?$/); + if (!calMatch) { + return new Response(JSON.stringify({ error: { message: `Unhandled URL ${urlStr}` } }), { status: 404 }); + } + const calendarId = decodeURIComponent(calMatch[1]); + const secondSeg = calMatch[2] ? decodeURIComponent(calMatch[2]) : undefined; + const calStore = ensureCal(calendarId); + + // GET list by iCalUID + if (method === 'GET' && !secondSeg) { + const uid = parsed.searchParams.get('iCalUID'); + const items = [...calStore.values()].filter((e) => !uid || e.iCalUID === uid); + return new Response(JSON.stringify({ items }), { status: 200 }); + } + + // POST /import + if (method === 'POST' && secondSeg === 'import') { + const id = `gev_${calendarId.split('@')[0]}_${seq++}`; + const record = { + id, + status: 'confirmed', + organizer: { email: HOLD_ORGANIZER }, + ...body, + }; + calStore.set(id, record); + return new Response(JSON.stringify(record), { status: 200 }); + } + + if (!secondSeg) { + return new Response(JSON.stringify({ error: { message: 'Missing event id' } }), { status: 400 }); + } + + const existing = calStore.get(secondSeg); + if (method === 'GET') { + if (!existing) return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }); + return new Response(JSON.stringify(existing), { status: 200 }); + } + + if (method === 'PATCH') { + if (!existing) return new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }); + const mergedAttendees = body.attendees + ? body.attendees.map((a: any) => { + const prev = (existing.attendees || []).find( + (p: any) => p.email?.toLowerCase() === a.email?.toLowerCase(), + ); + return { + email: a.email, + responseStatus: a.responseStatus || prev?.responseStatus || 'needsAction', + }; + }) + : existing.attendees; + const updated = { + ...existing, + ...body, + ...(mergedAttendees ? { attendees: mergedAttendees } : {}), + ...(body.extendedProperties + ? { + extendedProperties: { + private: { + ...(existing.extendedProperties?.private || {}), + ...(body.extendedProperties.private || {}), + }, + }, + } + : {}), + }; + calStore.set(secondSeg, updated); + return new Response(JSON.stringify(updated), { status: 200 }); + } + + if (method === 'DELETE') { + calStore.delete(secondSeg); + return new Response(null, { status: 204 }); + } + + return new Response(JSON.stringify({ error: { message: 'Unsupported method' } }), { status: 405 }); + }) as typeof fetch; + + return { + calendars, + calls, + fetchImpl, + getOrganizerEvent(eventId: string) { + return ensureCal(HOLD_ORGANIZER).get(eventId); + }, + setAttendeeResponse(eventId: string, attendeeEmail: string, responseStatus: string) { + const ev = ensureCal(HOLD_ORGANIZER).get(eventId); + assert.ok(ev, `Organizer event ${eventId} must exist`); + ev.attendees = (ev.attendees || []).map((a: any) => + a.email?.toLowerCase() === attendeeEmail.toLowerCase() ? { ...a, responseStatus } : a, + ); + if (!ev.attendees.some((a: any) => a.email?.toLowerCase() === attendeeEmail.toLowerCase())) { + ev.attendees.push({ email: attendeeEmail, responseStatus }); + } + }, + }; +} + diff --git a/backend/tests/podcast-first-contact.test.ts b/backend/tests/podcast-first-contact.test.ts index f8ab6a1ab..2da19e7c8 100644 --- a/backend/tests/podcast-first-contact.test.ts +++ b/backend/tests/podcast-first-contact.test.ts @@ -1,3 +1,6 @@ +import { PutCommand } from '@aws-sdk/lib-dynamodb'; +import { TABLE_USERS } from '../src/db/tableNames'; +import { renderGuestFirstContact } from '../src/events/guestFirstContact'; import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; @@ -14,12 +17,26 @@ import { buildHold } from '../src/calendar/holds'; import { templateLink } from './helpers/cardTemplate'; import { setSponsorDocEmailDepsForTests } from '../src/sponsorDocs/emailDelivery'; import { - PODCAST_DOCS_URL, PODCAST_FIRST_CONTACT_FROM, formatSlotLabel, - renderPodcastFirstContactEmail, } from '../src/podcast/firstContact'; +const approvedTemplate = `Subject: DataTalks.Club Podcast\n\n + +I'm Orin, I help Alexey organize the DataTalks.Club podcast. We'd love to have you on the show. + +Would at work for you? You can confirm this time or choose another available slot in the form below. If none of the times work, just reply to this email and we'll find an alternative. + +The form also explains how our podcast works and asks for a short bio, photo, profile links, and any blog posts, talks, or other materials that could help us prepare. + +Here's the link: + + +If prompted, enter this access code: + +Best regards, +Orin Vale`; + describe('podcast first-contact email (Orin ask)', () => { let client: DynamoDBDocumentClient; let handler: typeof import('../src/handler').handler; @@ -30,6 +47,7 @@ describe('podcast first-contact email (Orin ask)', () => { const port = await startLocal(); client = await getClient(port); await createTables(client); + await client.send(new PutCommand({ TableName: TABLE_USERS, Item: { PK: 'SETTINGS#text-templates', SK: 'SETTINGS#text-templates', version: 1, templates: [{ id: 'podcast.first-contact', title: 'Podcast first-contact', body: approvedTemplate, updatedAt: '2030-01-01T00:00:00Z' }] } })); process.env.IS_LOCAL = 'true'; handler = (await import('../src/handler')).handler; const user = await createUser(client, { @@ -63,131 +81,53 @@ describe('podcast first-contact email (Orin ask)', () => { body: body === undefined ? null : JSON.stringify(body), }, {}); - it('sends from orin@ with one proposed slot, the docs URL, and no card', async () => { - const email = renderPodcastFirstContactEmail({ - guestFirstName: 'Sofiia', - slotLabel: 'Tuesday, November 4 at 12:30 CET', - }); - assert.strictEqual(email.from, PODCAST_FIRST_CONTACT_FROM); - assert.strictEqual(email.from, 'orin@datatalks.club'); - assert.strictEqual(email.subject, 'Sofiia at DataTalks.Club Podcast'); - assert.ok(email.body.includes('Tuesday, November 4 at 12:30 CET')); - assert.ok(email.body.includes(PODCAST_DOCS_URL)); - assert.strictEqual( - PODCAST_DOCS_URL, - 'https://datatalksclub.github.io/docs/activities/podcast/', - ); - // No topic ask: the guest is never asked to propose topics in first contact. - assert.ok(!/ideas on the topic/i.test(email.body)); - assert.ok(!/do you already have ideas/i.test(email.body)); - // No draft card: the renderer returns copy only, never a card reference. - assert.ok(!/card/i.test(email.body)); - assert.ok(!('cardId' in email)); - // Exactly one slot: the single label appears once, no slot list. - assert.strictEqual(email.body.split('Tuesday, November 4 at 12:30 CET').length - 1, 1); - }); + const renderInput = { + startIso: '2030-11-04T16:00:00.000Z', guestFirstName: 'Synthetic', slotLabel: 'Monday 4 November 2030 at 17:00 CET', + formUrl: 'https://ops.example.test/podcast-docs/synthetic?code=ABCD-EFGH', + accessCode: 'ABCD-EFGH', topicSeed: 'Reliable data agents', + }; - it('links the public docs page, never the old Google Doc', () => { - const email = renderPodcastFirstContactEmail({ - guestFirstName: 'Jane', - slotLabel: 'Monday, November 3 at 12:30 CET', - }); - assert.ok(!/docs\.google\.com/.test(email.body)); - assert.ok(email.body.includes('https://datatalksclub.github.io/docs/activities/podcast/')); - }); + it('renders the exact approved first-contact subject and body', () => { + const email = renderGuestFirstContact(approvedTemplate, { ...renderInput, type: 'podcast', templateId: 'podcast.first-contact', guestName: renderInput.guestFirstName, topic: renderInput.topicSeed }); + assert.equal(email.from, PODCAST_FIRST_CONTACT_FROM); + assert.equal(email.subject, 'Synthetic at DataTalks.Club Podcast'); + assert.equal(email.body, `Hi Synthetic, - it('rejects blank guest name or slot instead of rendering unresolved copy', () => { - assert.throws( - () => renderPodcastFirstContactEmail({ guestFirstName: ' ', slotLabel: 'Monday at 12:00' }), - /guestFirstName is required/, - ); - assert.throws( - () => renderPodcastFirstContactEmail({ guestFirstName: 'Jane', slotLabel: ' ' }), - /slotLabel is required/, - ); - }); +I'm Orin, I help Alexey organize the DataTalks.Club podcast. We'd love to have you on the show to discuss Reliable data agents. - it('formats a hold instant as a Berlin slot label', () => { - // 2026-11-04 16:30 Europe/Berlin (CET) == 15:30Z. - assert.strictEqual( - formatSlotLabel('2026-11-04T15:30:00.000Z'), - 'Wednesday 4 November 2026 at 16:30 CET', - ); - assert.throws(() => formatSlotLabel('not-a-date'), /startIso must be a valid ISO instant/); - }); +Would Monday 4 November 2030 at 17:00 CET work for you? You can confirm this time or choose another available slot in the form below. If none of the times work, just reply to this email and we'll find an alternative. - it('POST /api/podcast-first-contact/email renders the same copy over HTTP', async () => { - const res = await staffReq('POST', '/api/podcast-first-contact/email', { - guestFirstName: 'Sofiia', - slotLabel: 'Tuesday, November 4 at 12:30 CET', - }); - assert.strictEqual(res.statusCode, 200, res.body); - const payload = JSON.parse(res.body); - assert.strictEqual(payload.from, 'orin@datatalks.club'); - assert.strictEqual(payload.subject, 'Sofiia at DataTalks.Club Podcast'); - assert.ok(payload.body.includes('Tuesday, November 4 at 12:30 CET')); - assert.ok(payload.body.includes('https://datatalksclub.github.io/docs/activities/podcast/')); - assert.ok(!('cardId' in payload)); - assert.ok(!('card' in payload)); - }); +The form also explains how our podcast works and asks for a short bio, photo, profile links, and any blog posts, talks, or other materials that could help us prepare. +Here's the link: +https://ops.example.test/podcast-docs/synthetic?code=ABCD-EFGH - it('does not ask the guest to invent alternate times; points at the form when given', () => { - const email = renderPodcastFirstContactEmail({ - guestFirstName: 'Ian', - slotLabel: 'Monday, November 3 at 12:30 CET', - formUrl: 'https://ops.dtcdev.click/sponsor-docs/abc?code=CODE', - topicSeed: 'building developer platforms', - }); - assert.ok(email.body.includes('building developer platforms')); - assert.ok(email.body.includes('https://ops.dtcdev.click/sponsor-docs/abc?code=CODE')); - assert.ok(/ordinary free Mon\/Tue slots/.test(email.body)); - assert.ok(!/couple of sentences/i.test(email.body)); - assert.ok(!/either way works/i.test(email.body)); - assert.ok(/if none fit, just reply to this email/i.test(email.body)); - assert.ok(!/[\u2013\u2014\u201c\u201d]/.test(email.body)); - assert.ok(email.body.endsWith('Best regards,\nOrin Vale')); - assert.ok(!/custom/.test(email.body)); - assert.equal(email.formUrl, 'https://ops.dtcdev.click/sponsor-docs/abc?code=CODE'); - assert.equal(email.topicSeed, 'building developer platforms'); +If prompted, enter this access code: ABCD-EFGH + +Best regards, +Orin Vale`); + assert.ok(!/datatalksclub.github.io|Nice to meet you|Blog posts\n|do not invent/i.test(email.body)); }); - it('keeps example publication bullets consecutive (no blank line between items)', () => { - const email = renderPodcastFirstContactEmail({ - guestFirstName: 'Sofiia', - slotLabel: 'Tuesday, November 4 at 12:30 CET', - }); - // Valeria/Orin: Gmail showed extra blank lines between "- Blog posts" items - // when the renderer joined with empty string lines. Tight list = single \n. - assert.ok( - email.body.includes( - '1. Blog posts\n2. Talks and podcast interviews\n3. Social media posts\n4. Github repositories\n\n', - ), - 'expected tight bullet block with single newlines between items', - ); - assert.ok( - !email.body.includes('- Blog posts\n\n- Talks'), - 'must not insert a blank line between consecutive list items', - ); + it('supports email-only guests without inventing a name or topic', () => { + const email = renderGuestFirstContact(approvedTemplate, { ...renderInput, type: 'podcast', templateId: 'podcast.first-contact' }); + assert.equal(email.subject, 'DataTalks.Club Podcast'); + assert.match(email.body, /^Hi,\n/); + assert.ok(!email.body.includes('to discuss')); + assert.throws(() => renderGuestFirstContact(approvedTemplate, { ...renderInput, type: 'podcast', templateId: 'podcast.first-contact', accessCode: '' }), /access code/); }); - it('form-only path: no reply-instead wording without formUrl either', () => { - const email = renderPodcastFirstContactEmail({ - guestFirstName: 'Sofiia', - slotLabel: 'Tuesday, November 4 at 12:30 CET', - }); - assert.ok(!/couple of sentences/i.test(email.body)); - assert.ok(!/either way works/i.test(email.body)); - assert.ok(!/reply to this email/i.test(email.body)); - assert.ok(/via the form/i.test(email.body)); - assert.ok(/do not invent alternate/i.test(email.body)); + it('formats the single saved slot with weekday, year and timezone', () => { + assert.equal(formatSlotLabel('2030-11-04T16:00:00.000Z'), renderInput.slotLabel); + assert.throws(() => formatSlotLabel('not-a-date'), /valid ISO/); }); - it('POST /api/podcast-first-contact/email 400s without a slot', async () => { - const res = await staffReq('POST', '/api/podcast-first-contact/email', { - guestFirstName: 'Sofiia', - }); - assert.strictEqual(res.statusCode, 400, res.body); + it('renders through the API without sending or creating records', async () => { + const res = await staffReq('POST', '/api/podcast-first-contact/email', renderInput); + assert.equal(res.statusCode, 200, res.body); + assert.equal(JSON.parse(res.body).body, renderGuestFirstContact(approvedTemplate, { ...renderInput, type: 'podcast', templateId: 'podcast.first-contact', guestName: renderInput.guestFirstName, topic: renderInput.topicSeed }).body); + const missing = await staffReq('POST', '/api/podcast-first-contact/email', { ...renderInput, accessCode: '' }); + assert.equal(missing.statusCode, 400, missing.body); }); async function invitation() { @@ -211,6 +151,7 @@ describe('podcast first-contact email (Orin ask)', () => { assert.match(output.email.body, /Monday 4 November 2030 at 17:00 CET/); assert.match(output.email.body, /Reliable data agents/); assert.match(output.email.formUrl, new RegExp(`/podcast-docs/${input.docId}\\?code=`)); + assert.ok(output.email.body.includes(`If prompted, enter this access code: ${output.email.accessCode}`)); assert.equal((await getTaskConsistent(client, input.taskId))!.podcastFirstContact, undefined); }); diff --git a/backend/tests/podcast-guest-form.test.ts b/backend/tests/podcast-guest-form.test.ts index 5bf074bda..074a2fa93 100644 --- a/backend/tests/podcast-guest-form.test.ts +++ b/backend/tests/podcast-guest-form.test.ts @@ -41,7 +41,7 @@ describe('podcast guest form (radio slot picker + topics; email stays one slot)' assert.ok(!/couple of sentences/i.test(template.intro)); assert.ok(!/either way works/i.test(template.intro)); assert.ok(!/reply to our email/i.test(template.intro)); - assert.ok(/confirm or change the recording slot/i.test(template.intro)); + assert.match(template.sections.find(section => section.id === 'scheduling')?.description || '', /confirm it, pick another free slot/i); const story = template.sections.find((section) => section.id === 'detailed-questions'); assert.equal(story?.collapsible, true); const notes = template.sections.find((section) => section.id === 'notes'); diff --git a/backend/tests/sponsor-doc-event-metadata.test.ts b/backend/tests/sponsor-doc-event-metadata.test.ts index 5f360f496..6c93ee57f 100644 --- a/backend/tests/sponsor-doc-event-metadata.test.ts +++ b/backend/tests/sponsor-doc-event-metadata.test.ts @@ -1,3 +1,5 @@ +import { saveGuestIntakeJob } from '../src/events/guestIntakeStore'; +import { readDocumentEventMetadata } from '../src/sponsorDocs/eventMetadata'; import { after, before, test } from 'node:test'; import assert from 'node:assert/strict'; import { GetCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; @@ -24,7 +26,7 @@ test('every event document reads the live hold after move/approve/release withou const card = await createCard(client, { ...templateLink(), title: 'Synthetic event', anchorDate: '2030-11-04', people: ['Test guest'] }); let hold = buildHold({ title: 'Synthetic event', topic: 'Synthetic topic', date: '2030-11-04', window: 'midday', cardId: card.id }, 'test'); await putCalendarHold(client, hold); - const templates: SponsorDocTemplateKey[] = ['podcast', 'webinar', 'podcast-guest-research', 'podcast-run-sheet', 'podcast-episode-brief']; + const templates: SponsorDocTemplateKey[] = ['podcast', 'webinar', 'workshop', 'podcast-guest-research', 'podcast-run-sheet', 'podcast-episode-brief']; const docs = []; for (const template of templates) { const { doc } = await createSponsorDoc(client, { cardId: card.id, template, title: 'Old stored title', publicationDate: '1999-01-01', createdBy: 'test' }); @@ -41,6 +43,9 @@ test('every event document reads the live hold after move/approve/release withou const live = (await getSponsorDoc(client, doc.id))!; assert.equal(live.eventMetadata!.date, '2030-12-03'); assert.equal(live.eventMetadata!.time, '12:30'); + assert.equal(live.eventMetadata!.startIso, hold.startIso); + assert.equal(live.eventMetadata!.endIso, hold.endIso); + assert.equal(live.eventMetadata!.durationMinutes, 60); assert.equal(live.eventMetadata!.state, stage === 'move' ? 'tentative' : stage === 'approve' ? 'confirmed' : 'released'); assert.match(live.title, /3 Dec 2030 12:30 Europe\/Berlin/); assert.ok(!live.title.includes('1999')); @@ -198,3 +203,18 @@ test('unified EventRecord transitions (propose/reschedule/approve/release) and e } }); + + +test('event metadata projects saved guest timezone without changing the Berlin event authority', async () => { + const card = await createCard(client, { ...templateLink(), title: 'Synthetic zone event', anchorDate: '2030-12-02' }); + const jobId = 'synthetic-zone-job'; + await saveGuestIntakeJob(client, { id: jobId, cardId: card.id, taskId: 'synthetic-task', actorId: 'test', type: 'podcast', creationKey: 'synthetic-key', fingerprint: 'synthetic-fingerprint', status: 'queued', skipEmail: true, guestTimezone: 'Asia/Kolkata', createdAt: '2030-01-01T00:00:00Z', updatedAt: '2030-01-01T00:00:00Z' }); + await updateCard(client, card.id, { expectedVersion: card.version, patch: { guestIntake: { jobId, taskId: 'synthetic-task', status: 'queued', skipEmail: true } } }); + const hold = buildHold({ title: 'Synthetic zone event', topic: 'Synthetic topic', date: '2030-12-02', window: 'evening', cardId: card.id }, 'test'); + await putCalendarHold(client, hold); + const metadata = await readDocumentEventMetadata(client, card.id, true); + assert.equal(metadata!.guestTimezone, 'Asia/Kolkata'); + assert.equal(metadata!.timeZone, 'Europe/Berlin'); + assert.equal(metadata!.startIso, hold.startIso); + assert.equal(metadata!.endIso, hold.endIso); +}); diff --git a/cli/src/commands/calendar.mjs b/cli/src/commands/calendar.mjs index 5ff3a7563..4e6085ab9 100644 --- a/cli/src/commands/calendar.mjs +++ b/cli/src/commands/calendar.mjs @@ -766,6 +766,7 @@ function formatHold(hold) { * Propose a tentative hold: DataOps + Google "(Proposal)" events. */ export async function propose(args, io) { + if (args.card) throw new UsageError("Use dataops event propose CARD_ID --date YYYY-MM-DD --guest-emails EMAIL for a linked [Proposed] guest invitation. calendar propose creates a private standalone hold."); const payload = proposeArgs(args); const result = await client(args).post("/api/calendar-holds", payload); if (args.json) return result; diff --git a/cli/src/commands/cards.mjs b/cli/src/commands/cards.mjs index 1d253bfe8..0b4bccd1a 100644 --- a/cli/src/commands/cards.mjs +++ b/cli/src/commands/cards.mjs @@ -1,4 +1,5 @@ import { banner } from './cardBanners.mjs'; +import { randomUUID } from 'node:crypto'; import { UsageError } from "../errors.mjs"; import { calendarLabel, enc, fields, integer, isoDate, linkedCardLabel, list as csv, optionalString, portalUrl, positional, required, table } from "../format.mjs"; import { apiFor } from "../session.mjs"; @@ -22,8 +23,20 @@ Usage dataops cards banner --job JOB_ID [--wait] Read durable progress; completed images need review dataops cards show The Card, its links, and its Tasks - dataops cards create --date YYYY-MM-DD --template ID|TYPE|NAME [--title T] + dataops cards create --template ID|TYPE|NAME [--date YYYY-MM-DD] [--title T] [--end-date YYYY-MM-DD] [--optional-groups a,b] [--count FIELD=N,...] [--stage S] [--description D] [--owner ID] [--tags a,b] + [--guest-email EMAIL] [--guest-person ID | --guest-contact ID] [--guest-name NAME] [--topic TOPIC] [--time HH:MM] [--time-zone IANA_ZONE] + [--guest-time-zone IANA_ZONE] [--window WINDOW] [--skip-email] [--key KEY] + Podcast, webinar and workshop guest Cards send email + and a [Proposed] calendar invite automatically. Date, title, name and topic are optional. + --skip-email prepares its event and form without + contacting the guest. Reuse --key for a safe retry. + dataops cards invitation [--preview | --send | --retry-pre-send] [--json] + Read first-invitation status; --preview renders without sending. + --send sends email AND a [Proposed] calendar invite only + for a previously skipped first invitation; never a resend. + --retry-pre-send resumes only a proven safe pre-send failure; + ambiguous delivery is blocked. The worker normally resumes automatically. dataops cards update [--title T] [--description D] [--date D] [--end-date D] [--owner ID|none] [--tags a,b] [--people PERSON_ID,PERSON_ID|none] dataops cards update --count FIELD=N[,FIELD=N] [--apply] @@ -195,7 +208,46 @@ export async function show(args, io) { export async function create(args, io) { const api = apiFor(args); - const body = { anchorDate: isoDate(required(args, "date", "date"), "date") }; + const body = {}; + const guestEmail = optionalString(args, "guestEmail")?.trim(); + if (guestEmail !== undefined && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail)) { + throw new UsageError('--guest-email must be a valid email address.'); + } + const guestPersonId = optionalString(args, "guestPerson"); + const guestContactId = optionalString(args, "guestContact"); + const hasGuest = Boolean(guestEmail || guestPersonId || guestContactId || optionalString(args, "guestName")); + const date = hasGuest ? optionalString(args, "date") : required(args, "date", "date"); + if (guestPersonId) body.guestPersonId = guestPersonId; + if (guestContactId) body.guestContactId = guestContactId; + if (date !== undefined) body.anchorDate = isoDate(date, "date"); + if (hasGuest) { + if (guestEmail) body.guestEmail = guestEmail.toLowerCase(); + body.creationKey = optionalString(args, "key") || randomUUID(); + } else if (args.key !== undefined) body.creationKey = optionalString(args, "key"); + for (const field of ["guestName", "topic"]) { + const value = optionalString(args, field); + if (value !== undefined) body[field] = value.trim(); + } + if (args.skipEmail !== undefined) { + if (![true, false, "true", "false"].includes(args.skipEmail)) { + throw new UsageError('--skip-email is a boolean flag.'); + } + body.skipEmail = args.skipEmail === true || args.skipEmail === "true"; + } + const time = optionalString(args, "time"); + if (time !== undefined) { + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new UsageError('--time must be HH:MM.'); + body.time = time; + } + for (const [flag, field] of [["timeZone", "timeZone"], ["guestTimeZone", "guestTimezone"]]) { + const zone = optionalString(args, flag); + if (zone !== undefined) { + try { new Intl.DateTimeFormat("en", { timeZone: zone }).format(); } catch { throw new UsageError(`--${flag === "timeZone" ? "time-zone" : "guest-time-zone"} must be an IANA time zone.`); } + body[field] = zone; + } + } + const window = optionalString(args, "window"); + if (window !== undefined) body.window = window; const endDate = optionalString(args, "endDate"); if (endDate !== undefined) body.endDate = isoDate(endDate, "end-date"); const title = optionalString(args, "title"); @@ -214,6 +266,8 @@ export async function create(args, io) { const result = await api.post("/api/cards", body); if (args.json) return result; io.print(`Created Card ${result.card.id} - ${result.card.title} (${result.tasks?.length ?? 0} Tasks).`); + const intake = result.card.guestIntake; + if (intake) io.print(`Invitation: ${intake.status === "sent" && !intake.messageId ? "preparing" : intake.status}${intake.recipient ? ` to ${intake.recipient}` : ""}${intake.taskId ? ` (Task ${intake.taskId})` : ""}.`); for (const task of result.tasks || []) io.print(` ${task.id} ${task.date} ${task.description}`); for (const slot of result.eventSlots || []) { if (slot.outcome === "created") io.print(`Event Card ${slot.cardId} - ${slot.cardTitle} (date TBD; set it with: dataops cards time ${slot.cardId} --at "YYYY-MM-DD HH:MM")`); @@ -495,7 +549,34 @@ export async function time(args, io) { return result; } +export async function invitation(args, io) { + const id = positional(args, 0, "dataops cards invitation [--preview | --send | --retry-pre-send]"); + const modes = ["preview", "send", "retryPreSend"].filter(flag => args[flag] !== undefined && args[flag] !== false && args[flag] !== "false"); + if (modes.length > 1) throw new UsageError("Choose only one of --preview, --send or --retry-pre-send."); + for (const flag of modes) if (args[flag] !== true && args[flag] !== "true") throw new UsageError("Invitation action options are boolean flags."); + const api = apiFor(args); + const route = `/api/cards/${enc(id)}/guest-intake`; + const result = args.send === true || args.send === "true" ? await api.post(`${route}/send`, {}) + : args.retryPreSend === true || args.retryPreSend === "true" ? await api.post(`${route}/retry`, {}) + : await api.get(route, args.preview === true || args.preview === "true" ? { query: { preview: "1" } } : undefined); + if (args.json) return result; + const receipt = result.invitation || result.job || result.card?.guestIntake || result; + const state = receipt.status === "sent" && !receipt.messageId ? "preparing" : receipt.status || "unknown"; + io.print(`First invitation: ${state}`); + for (const [label, value] of [["Recipient", receipt.recipient || receipt.guestEmail], ["Task", receipt.taskId], ["Gmail message ID", receipt.messageId], ["Sent at", receipt.sentAt], ["Error", receipt.error]]) { + if (value) io.print(`${label}: ${value}`); + } + const preview = result.email || (typeof result.preview === "object" ? result.preview : undefined); + if (preview?.subject && typeof preview.body === "string") { + io.print(`Subject: ${preview.subject}`); + io.print(""); + io.print(preview.body); + } else if (modes.includes("preview")) io.print(JSON.stringify(preview || result, null, 2)); + return result; +} + export const commands = { + invitation, banner, "dedupe-tasks": dedupeTasks, "event-slots": eventSlots, diff --git a/cli/src/commands/podcast.mjs b/cli/src/commands/podcast.mjs index b427c3d25..6c48eb46a 100644 --- a/cli/src/commands/podcast.mjs +++ b/cli/src/commands/podcast.mjs @@ -24,7 +24,7 @@ Usage Create a Card, then calendar propose --card CARD-ID, then podcast doc create CARD-ID before sending. dataops podcast invite --guest "Full Name" --email guest@example.com --date YYYY-MM-DD --topic TOPIC - [--title T] [--window midday|evening] [--duration N] [--form-url URL] + --form-url URL [--access-code CODE] [--title T] [--window midday|evening] [--duration N] First contact: tentative Google "(Proposal)" hold for one slot, a standalone follow-up Task (no Card), and the send-ready email @@ -109,7 +109,7 @@ edited, scheduled and published. --status corrects or cancels an episode. Examples dataops podcast list --status booked - dataops podcast invite --guest "Sofiia" --email sofiia@example.com --date 2026-11-04 --topic "LLM evals in production" + dataops podcast invite --guest "Sofiia" --email sofiia@example.com --date 2026-11-04 --topic "LLM evals in production" --form-url "https://ops.example.test/podcast-docs/DOC?code=CODE" dataops podcast create --guests "Jane Doe:jane-doe" --title "Shipping ML" --recording-date 2026-11-04 dataops podcast update 6f1c... --status cancelled dataops podcast create-card 6f1c... @@ -545,6 +545,12 @@ export async function invite(args, io) { throw new UsageError("--duration must be a positive integer number of minutes (podcasts use 60)."); } const title = optionalString(args, "title") || `${guest} at DataTalks.Club Podcast`; + const formUrl = optionalString(args, "form-url") || optionalString(args, "formUrl"); + if (!formUrl || !/^https:\/\//.test(formUrl)) throw new UsageError('--form-url must be an https guest materials share URL.'); + let accessCode = optionalString(args, 'accessCode'); + try { accessCode ||= new URL(formUrl).searchParams.get('code') || undefined; } + catch { throw new UsageError('--form-url must be a valid https URL.'); } + if (!accessCode) throw new UsageError('--access-code is required when the form URL has no code query.'); const api = apiFor(args); const holdRes = await api.post("/api/calendar-holds", { @@ -575,15 +581,13 @@ export async function invite(args, io) { } } const firstName = guest.split(/\s+/)[0]; - const formUrl = optionalString(args, "form-url") || optionalString(args, "formUrl") || undefined; - if (formUrl && !/^https:\/\//.test(formUrl)) { - throw new UsageError("--form-url must be an https URL (guest materials share link)."); - } const rendered = await api.post("/api/podcast-first-contact/email", { guestFirstName: firstName, slotLabel, + startIso: hold?.startIso, formUrl, topicSeed: topic, + accessCode, }); if (args.json) return { hold, task, email: rendered }; diff --git a/cli/test/calendar.test.mjs b/cli/test/calendar.test.mjs index cd05c8037..0b8e514a1 100644 --- a/cli/test/calendar.test.mjs +++ b/cli/test/calendar.test.mjs @@ -701,26 +701,14 @@ describe("Calendar hold CLI commands", () => { assert.match(io.text(), /Google sync deferred/); }); - test("calendar propose wires workshop flags: 90 min evening with card", async () => { + test("calendar propose directs Card-linked guest invitations to the unified event command before writing", async () => { const io = captureIo(); - let seen = null; - globalThis.fetch = async (url, options = {}) => { - seen = { url: String(url), body: JSON.parse(options.body) }; - return new Response( - JSON.stringify({ hold: { id: "hold_w9", date: "2026-10-27", window: "evening", durationMinutes: 90, title: "Workshop", status: "tentative" } }), - { status: 201 }, - ); - }; - const code = await run( - ["calendar", "propose", "--date", "2026-10-27", "--window", "evening", "--duration", "90", "--title", "Workshop", "--topic", "Workshop topic", "--guest-ref", "guest@example.com", "--document-url", "https://example.com/outline", "--card", "card-1"], - io, - ); - assert.equal(code, 0); - assert.equal(seen.body.durationMinutes, 90); - assert.equal(seen.body.window, "evening"); - assert.equal(seen.body.cardId, "card-1"); - assert.equal(seen.body.guestRef, "guest@example.com"); - assert.equal(seen.body.documentUrl, "https://example.com/outline"); + let calls = 0; + globalThis.fetch = async () => { calls++; throw new Error('No provider request expected'); }; + const code = await run(["calendar", "propose", "--date", "2030-12-02", "--title", "Workshop", "--topic", "Workshop topic", "--card", "card-1"], io); + assert.equal(code, 2); + assert.equal(calls, 0); + assert.match(io.errors.join("\n"), /dataops event propose CARD_ID/); }); test("calendar propose validates before sending", async () => { diff --git a/cli/test/cards-invitation.test.mjs b/cli/test/cards-invitation.test.mjs new file mode 100644 index 000000000..b97f14220 --- /dev/null +++ b/cli/test/cards-invitation.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { captureIo, fakePortal, isolateConfig } from './helpers.mjs'; +isolateConfig(); +const originalFetch = globalThis.fetch; +const { run } = await import('../src/cli.mjs'); +afterEach(() => { globalThis.fetch = originalFetch; }); +test('invitation status and preview are read-only, preserve evidence and show rendered copy', async () => { + const calls = fakePortal({ 'GET /api/cards/synthetic-card/guest-intake': ({ query }) => ({ invitation: { status: 'sent', recipient: 'guest@example.test', taskId: 'task-test', messageId: 'gmail-test' }, ...(query.preview ? { preview: true, email: { subject: 'Synthetic guest', body: 'Synthetic invitation body.' } } : {}) }) }); + const status = captureIo(); + assert.equal(await run(['cards', 'invitation', 'synthetic-card'], status), 0); + assert.match(status.text(), /First invitation: sent/); + assert.match(status.text(), /Gmail message ID: gmail-test/); + const preview = captureIo(); + assert.equal(await run(['cards', 'invitation', 'synthetic-card', '--preview'], preview), 0); + assert.match(preview.text(), /Subject: Synthetic guest/); + assert.match(preview.text(), /Synthetic invitation body/); + assert.equal(calls.at(-1).query.preview, '1'); + assert.ok(calls.every(call => call.method === 'GET')); +}); +test('first-invitation send and proven pre-send recovery invoke only their guarded server routes', async () => { + const calls = fakePortal({ + 'POST /api/cards/synthetic-card/guest-intake/send': { invitation: { status: 'queued' } }, + 'POST /api/cards/synthetic-card/guest-intake/retry': { invitation: { status: 'queued' } }, + }); + for (const action of ['--send', '--retry-pre-send']) { + const io = captureIo(); + assert.equal(await run(['cards', 'invitation', 'synthetic-card', action], io), 0); + assert.match(io.text(), /First invitation: queued/); + assert.doesNotMatch(io.text(), /First invitation: sent/); + assert.deepEqual(calls.at(-1).body, {}); + } + assert.equal(calls.length, 2); +}); +test('conflicting invitation actions fail before requests', async () => { + const calls = fakePortal({}); + assert.equal(await run(['cards', 'invitation', 'synthetic-card', '--send', '--preview'], captureIo()), 2); + assert.equal(calls.length, 0); +}); diff --git a/cli/test/podcast.test.mjs b/cli/test/podcast.test.mjs index cd16856fc..b09650130 100644 --- a/cli/test/podcast.test.mjs +++ b/cli/test/podcast.test.mjs @@ -407,7 +407,7 @@ describe("podcast publication evidence flags", () => { }); const io = captureIo(); const code = await run( - ["podcast", "invite", "--guest", "Sofiia", "--email", "sofiia@example.com", "--date", "2026-11-04", "--topic", "LLM evals in production"], + ["podcast", "invite", "--guest", "Sofiia", "--email", "sofiia@example.com", "--date", "2026-11-04", "--topic", "LLM evals in production", "--form-url", "https://ops.example.test/podcast-docs/doc?code=ABCD-EFGH"], io, ); assert.equal(code, 0); @@ -429,6 +429,7 @@ describe("podcast publication evidence flags", () => { assert.equal(calls[2].body.guestFirstName, "Sofiia"); assert.match(calls[2].body.slotLabel, /12:30/); assert.equal(calls[2].body.topicSeed, "LLM evals in production"); + assert.equal(calls[2].body.accessCode, "ABCD-EFGH"); // Printed email: from orin@, one slot, docs URL, send reminder. assert.match(io.text(), /From: orin@datatalks\.club/); assert.match(io.text(), /To: sofiia@example\.com/); @@ -461,7 +462,7 @@ describe("podcast publication evidence flags", () => { }, }); const code = await run( - ["podcast", "invite", "--guest", "Sofiia", "--email", "sofiia@example.com", "--date", "2026-11-04", "--topic", "LLM evals", "--window", "evening"], + ["podcast", "invite", "--guest", "Sofiia", "--email", "sofiia@example.com", "--date", "2026-11-04", "--topic", "LLM evals", "--window", "evening", "--form-url", "https://ops.example.test/podcast-docs/doc?code=ABCD-EFGH"], captureIo(), ); assert.equal(code, 0); diff --git a/cli/test/work.test.mjs b/cli/test/work.test.mjs index 7f643e5ce..913e10444 100644 --- a/cli/test/work.test.mjs +++ b/cli/test/work.test.mjs @@ -431,6 +431,64 @@ describe("cards and templates", () => { assert.deepEqual(calls.at(-1).body, { anchorDate: "2026-11-02", templateId: "tpl-1" }); }); + test("guest event creation needs only template and email and creates a retry key", async () => { + const calls = fakePortal({ + "GET /api/templates/event-type": { template: { id: "tpl-event", type: "event-type", guestIntake: { enabled: true } } }, + "POST /api/cards": ({ body }) => ({ card: { id: "guest-card", ...body }, tasks: [] }), + }); + assert.equal(await run(["cards", "create", "--template", "event-type", "--guest-email", "Guest@Example.test"], captureIo()), 0); + const sent = calls.at(-1).body; + assert.equal(sent.templateId, "tpl-event"); + assert.equal(sent.guestEmail, "guest@example.test"); + assert.match(sent.creationKey, /^[0-9a-f-]{36}$/); + for (const field of ["anchorDate", "title", "guestName", "topic"]) assert.ok(!(field in sent)); + assert.ok(calls.every(({ route }) => !/gmail|send-invite/.test(route))); + }); + + test("guest event creation preserves explicit optional values, skip email and replay key", async () => { + const calls = fakePortal({ + "GET /api/templates/event-type": { template: { id: "tpl-event", type: "event-type", eventPolicy: {} } }, + "POST /api/cards": ({ body }) => ({ card: { id: "guest-card", ...body }, tasks: [] }), + }); + const command = ["cards", "create", "--template", "event-type", "--guest-email", "guest@example.test", "--guest-name", "Synthetic Guest", "--topic", "Practical workflows", "--date", "2026-12-14", "--skip-email", "--key", "same-explicit-key"]; + assert.equal(await run(command, captureIo()), 0); + const sent = calls.at(-1).body; + assert.deepEqual(sent, { templateId: "tpl-event", guestEmail: "guest@example.test", guestName: "Synthetic Guest", topic: "Practical workflows", anchorDate: "2026-12-14", skipEmail: true, creationKey: "same-explicit-key" }); + assert.equal(await run(command, captureIo()), 0); + assert.deepEqual(calls.at(-1).body, sent); + }); + + test("saved guest references reuse the private contact without a required date", async () => { + const calls = fakePortal({ + "GET /api/templates/podcast": { template: { id: "tpl-event", type: "podcast", guestIntake: { enabled: true } } }, + "POST /api/cards": ({ body }) => ({ card: { id: "guest-card", ...body, guestIntake: { status: "queued", recipient: "saved@example.test" } } }), + }); + const io = captureIo(); + assert.equal(await run(["cards", "create", "--template", "podcast", "--guest-person", "saved-person"], io), 0); + assert.match(io.text(), /Invitation: queued to saved@example.test/); + assert.doesNotMatch(io.text(), /Invitation: sent/); + assert.equal(calls.at(-1).body.guestPersonId, "saved-person"); + assert.ok(!("anchorDate" in calls.at(-1).body)); + assert.match(calls.at(-1).body.creationKey, /^[0-9a-f-]{36}$/); + }); + + test("guest creation passes explicit scheduling inputs without converting their time zone", async () => { + const calls = fakePortal({ + "GET /api/templates/podcast": { template: { id: "tpl-event" } }, + "POST /api/cards": ({ body }) => ({ card: { id: "guest-card", ...body } }), + }); + assert.equal(await run(["cards", "create", "--template", "podcast", "--guest-email", "guest@example.test", "--date", "2030-12-02", "--time", "18:30", "--time-zone", "America/New_York", "--guest-time-zone", "Asia/Kolkata", "--window", "evening"], captureIo()), 0); + const body = calls.at(-1).body; + assert.equal(body.time, "18:30"); assert.equal(body.timeZone, "America/New_York"); + assert.equal(body.guestTimezone, "Asia/Kolkata"); assert.equal(body.window, "evening"); + }); + + test("invalid guest email is rejected before creating a Card", async () => { + const calls = fakePortal({}); + assert.equal(await run(["cards", "create", "--template", "event-type", "--guest-email", "not-an-email"], captureIo()), 2); + assert.equal(calls.length, 0); + }); + test("an unknown template is a usage error before anything is created", async () => { const calls = fakePortal({ "GET /api/templates/nope": { status: 404, body: { error: "Template not found" } }, diff --git a/frontend/src/surfaces/operations/sponsor-doc-event-time.js b/frontend/src/surfaces/operations/sponsor-doc-event-time.js new file mode 100644 index 000000000..b3a6e791e --- /dev/null +++ b/frontend/src/surfaces/operations/sponsor-doc-event-time.js @@ -0,0 +1,36 @@ +export function validEventTimeZone(zone) { + try { new Intl.DateTimeFormat("en", { timeZone: zone }).format(); return Boolean(zone); } catch { return false; } +} +export function guestEventTimeZone(saved) { + if (validEventTimeZone(saved)) return saved; + const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; + return validEventTimeZone(detected) ? detected : "Europe/Berlin"; +} +function durationMinutes(startIso, endIso, fallback) { + const duration = (Date.parse(endIso) - Date.parse(startIso)) / 60000; + return Number.isFinite(duration) && duration > 0 ? Math.round(duration) : Number(fallback) || 0; +} +export function formatGuestEventTime(startIso, endIso, zone, fallbackDuration, includeDuration = true) { + const start = new Date(startIso), end = new Date(endIso); + if (!startIso || Number.isNaN(start.getTime()) || !validEventTimeZone(zone)) return ""; + const validEnd = endIso && !Number.isNaN(end.getTime()) && end > start; + const dateFormat = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: zone }); + const timeFormat = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: zone }); + const offset = instant => new Intl.DateTimeFormat("en-GB", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find(part => part.type === "timeZoneName")?.value || zone; + let range = `${timeFormat.format(start)} ${offset(start)}`; + if (validEnd) { + const sameDay = dateFormat.format(start) === dateFormat.format(end); + const sameOffset = offset(start) === offset(end); + range = `${timeFormat.format(start)}${sameOffset ? "" : ` ${offset(start)}`}–${sameDay ? "" : `${dateFormat.format(end)}, `}${timeFormat.format(end)} ${offset(end)}`; + } + const duration = durationMinutes(startIso, endIso, fallbackDuration); + return `${dateFormat.format(start)}, ${range} (${zone})${includeDuration && duration ? `; ${duration} minutes` : ""}`; +} +export function dualZoneEventTime(startIso, endIso, guestZone, fallbackDuration) { + const berlin = formatGuestEventTime(startIso, endIso, "Europe/Berlin", fallbackDuration, false); + if (!berlin) return ""; + const duration = durationMinutes(startIso, endIso, fallbackDuration); + const rows = [`Berlin: ${berlin}`]; + if (guestZone !== "Europe/Berlin") rows.push(`Your time: ${formatGuestEventTime(startIso, endIso, guestZone, fallbackDuration, false)}`); + return `${rows.join("\n")}${duration ? `; ${duration} minutes` : ""}`; +} diff --git a/frontend/src/surfaces/operations/sponsor-doc-radio.js b/frontend/src/surfaces/operations/sponsor-doc-radio.js index c31490219..dfc4c784a 100644 --- a/frontend/src/surfaces/operations/sponsor-doc-radio.js +++ b/frontend/src/surfaces/operations/sponsor-doc-radio.js @@ -68,6 +68,7 @@ export function appendSponsorDocRadioField({ updateLivePreview(); }); const text = el("span", "sponsor-doc-radio__text", opt.label); + text.style.whiteSpace = "pre-line"; label.append(input, text); radios.push(input); bandWrap.append(label); diff --git a/frontend/src/surfaces/operations/sponsor-docs.js b/frontend/src/surfaces/operations/sponsor-docs.js index 032c20007..f010c7199 100644 --- a/frontend/src/surfaces/operations/sponsor-docs.js +++ b/frontend/src/surfaces/operations/sponsor-docs.js @@ -1,3 +1,4 @@ +import { guestEventTimeZone, dualZoneEventTime } from "./sponsor-doc-event-time.js"; import { formatUserCode } from "../../dakit/device-code.js"; import { renderSponsorDocGate } from "./sponsor-doc-gate.js"; import { appendSponsorDocRadioField } from "./sponsor-doc-radio.js"; @@ -256,7 +257,7 @@ export function createSponsorDocPage({ } } - async function saveChanges(nextStatus) { + async function saveChanges(nextStatus, timeChoiceIntent) { if (busy || !doc) return; if (nextStatus === "submitted") { const validationErrors = validateSponsorDocDraft(doc.templateDefinition, draftContent); @@ -279,6 +280,7 @@ export function createSponsorDocPage({ (section.fields || []).filter(field => field.derived).map(field => field.key))); const body = { content: Object.fromEntries(Object.entries(draftContent).filter(([key]) => !derivedKeys.has(key))) }; if (nextStatus) body.status = nextStatus; + if (timeChoiceIntent) body.timeChoiceIntent = timeChoiceIntent; const payload = await request(`/api/sponsor-docs/${encodeURIComponent(docId)}`, { method: "PUT", headers, @@ -402,9 +404,15 @@ export function createSponsorDocPage({ const metaRow = el("div", "sponsor-doc-meta"); metaRow.append(el("span", "dk-badge", tDef.label || doc.template)); + const guestSchedule = ["podcast", "webinar", "workshop"].includes(doc.template) && doc.eventMetadata?.startIso; + const guestZone = guestEventTimeZone(draftContent.guestTimezone || doc.eventMetadata?.guestTimezone); + if (guestSchedule && !draftContent.guestTimezone) draftContent.guestTimezone = guestZone; if (doc.eventMetadata) { - const eventLabel = el("span", "", doc.eventMetadata.label); + const eventLabel = el("span", "", guestSchedule + ? dualZoneEventTime(doc.eventMetadata.startIso, doc.eventMetadata.endIso, guestZone, doc.eventMetadata.durationMinutes) + : doc.eventMetadata.label); eventLabel.dataset.sponsorDocEvent = ""; + eventLabel.style.whiteSpace = "pre-line"; metaRow.append(eventLabel); } if (doc.publicationDate) metaRow.append(el("span", "", `Publication date: ${doc.publicationDate}`)); @@ -414,6 +422,19 @@ export function createSponsorDocPage({ metaRow.append(editedSpan); headWrap.append(titleRow, metaRow); headerCard.append(headWrap); + if (guestSchedule) { + const zoneLabel = el("label", "sponsor-doc-field__label", "Your time zone"); + const zoneSelect = el("select", "dk-input"); + zoneSelect.dataset.guestTimezone = ""; + zoneSelect.setAttribute("aria-label", "Your time zone"); + const zones = new Set([guestZone, "Europe/Berlin", "UTC", ...(Intl.supportedValuesOf?.("timeZone") || [])]); + for (const zone of zones) { const option = el("option", "", zone); option.value = zone; zoneSelect.append(option); } + zoneSelect.value = guestZone; + zoneSelect.disabled = busy; + zoneSelect.addEventListener("change", () => { draftContent.guestTimezone = zoneSelect.value; render(); }); + zoneLabel.append(zoneSelect); + headerCard.append(zoneLabel, el("p", "dk-hint", "Times show the Berlin schedule and your local date and time. You can save a time choice now and complete the questionnaire later.")); + } if (doc.viewerRole === "staff") { const staffBar = el("div", "sponsor-doc-staff-bar"); @@ -491,7 +512,7 @@ export function createSponsorDocPage({ if (tDef.intro) body.append(el("p", "", tDef.intro)); if (tDef.guidelinesUrl) { const guideP = el("p", ""); - const guideLink = el("a", "", "View visual placement guide →"); + const guideLink = el("a", "", tDef.guidelinesLabel || "View visual placement guide →"); guideLink.href = tDef.guidelinesUrl; guideLink.target = "_blank"; guideLink.rel = "noopener noreferrer"; @@ -606,6 +627,7 @@ export function createSponsorDocPage({ }; for (const fieldDef of sectionDef.fields || []) { + if (guestSchedule && ["guestTimezone", "proposedSlotLabel"].includes(fieldDef.key)) continue; const fieldWrap = el("div", "sponsor-doc-field"); fieldWrap.dataset.sponsorFieldGroup = fieldDef.key; if (fieldDef.required) fieldWrap.dataset.sponsorFieldRequired = "true"; @@ -659,6 +681,7 @@ export function createSponsorDocPage({ const isVisualField = fieldDef.key === "graphicsUrl" || Boolean(fieldDef.allowVisualUpload); const updateCount = (valText) => { + if (fieldDef.key === "photoUrl") { countEl.hidden = true; countEl.textContent = ""; return; } const text = String(valText || ""); if (isVisualField && /^data:image\//i.test(text)) { countEl.textContent = uploadedVisualName ? `Uploaded (${uploadedVisualName})` : "Uploaded image"; @@ -693,7 +716,15 @@ export function createSponsorDocPage({ appendSponsorDocRadioField({ el, fieldWrap, - fieldDef, + fieldDef: fieldDef.key === "slotChoice" && guestSchedule ? { + ...fieldDef, + options: (fieldDef.options || []).map(option => { + const start = option.startIso || (option.value === "confirm_proposed" ? doc.eventMetadata.startIso : undefined); + const end = option.endIso || (option.value === "confirm_proposed" ? doc.eventMetadata.endIso : undefined); + const label = dualZoneEventTime(start, end, guestZone, doc.eventMetadata.durationMinutes); + return label ? { ...option, label: `${option.value === "confirm_proposed" ? "Confirm proposed time: " : ""}${label}` } : option; + }), + } : fieldDef, inputId, currentVal, busy, @@ -701,6 +732,17 @@ export function createSponsorDocPage({ updateLivePreview, top, }); + if (fieldDef.key === "slotChoice" && guestSchedule) { + fieldWrap.append(actionBtn("dk-button dk-button--secondary", busy ? "Saving…" : "Save time choice", "save-time-choice", () => { + if (!draftContent.slotChoice) { + feedbackMsg = "Choose a time option first."; + feedbackTone = "warning"; + render(); + return; + } + saveChanges(undefined, { choice: draftContent.slotChoice, proposalStartIso: doc.eventMetadata.startIso, proposalVersion: doc.eventMetadata.version }); + }, busy)); + } appendErrorIfAny(); appendFieldNode(fieldDef.key, fieldWrap); continue; @@ -824,6 +866,11 @@ export function createSponsorDocPage({ if (!file) return; const dataUrl = await readVisualFileAsDataUrl(file); if (!dataUrl) return; + if (fieldDef.maxCharsHint && dataUrl.length > fieldDef.maxCharsHint) { + announce("This image is too large. Please use a smaller photo or paste an image URL."); + fileInput.value = ""; + return; + } uploadedVisualName = file.name || "uploaded-visual.png"; draftContent[fieldDef.key] = dataUrl; control.value = ""; diff --git a/frontend/src/surfaces/tasks/quick-create.js b/frontend/src/surfaces/tasks/quick-create.js index c3b1c4a85..1dd9c75a2 100644 --- a/frontend/src/surfaces/tasks/quick-create.js +++ b/frontend/src/surfaces/tasks/quick-create.js @@ -140,7 +140,14 @@ export function createQuickTaskActions(context) { } async function openQuickWorkflowForm(options = {}) { + const draftStorageKey = "dataops.guest-card-draft"; + let savedDraft = {}; + try { savedDraft = JSON.parse(globalThis.sessionStorage?.getItem(draftStorageKey) || "{}"); } catch {} + options = { ...savedDraft, ...options }; const requestedTemplate = options.template || null; + const creationKey = options.creationKey || globalThis.crypto?.randomUUID?.() + || `card-${Date.now()}-${Math.random().toString(36).slice(2)}`; + options.creationKey = creationKey; const overlay = createQuickFormOverlay("Create card"); const form = document.createElement("form"); form.className = "quick-form"; @@ -161,9 +168,74 @@ export function createQuickTaskActions(context) { const anchorInput = createQuickInput( "Date", "date", - options.anchorDate || todayIsoDate(), + options.anchorDate ?? todayIsoDate(), ); + let anchorEdited = Object.hasOwn(options, "anchorDate"); + anchorInput.input.addEventListener("input", () => { anchorEdited = true; }); + anchorInput.input.addEventListener("change", () => { anchorEdited = true; }); const titleInput = createQuickInput("Card title", "text", options.title || ""); + const guestWrap = document.createElement("div"); + guestWrap.hidden = true; + guestWrap.className = "quick-form"; + guestWrap.dataset.guestEventFields = ""; + const guestEmail = createQuickInput("Guest email", "email", options.guestEmail || ""); + guestEmail.input.dataset.guestEmail = ""; + const guestName = createQuickInput("Guest name (optional)", "text", options.guestName || ""); + guestName.input.dataset.guestName = ""; + const topic = createQuickInput("Topic (optional)", "text", options.topic || ""); + topic.input.dataset.guestTopic = ""; + const guestSearch = createQuickInput("Find a saved guest", "search", ""); + guestSearch.input.dataset.guestSearch = ""; + const findGuest = document.createElement("button"); + findGuest.type = "button"; + findGuest.className = "quiet-button"; + findGuest.textContent = "Find guest"; + const guestOption = (label, value) => { const option = document.createElement("option"); option.textContent = label; option.value = value; return option; }; + const savedGuest = document.createElement("select"); + savedGuest.dataset.savedGuest = ""; + savedGuest.className = "quick-form-select"; + savedGuest.setAttribute("aria-label", "Saved guest"); + savedGuest.append(guestOption("Select a saved guest (optional)", "")); + let savedPeople = []; + let savedPersonId = options.guestPersonId || options.guestContactId || ""; + if (savedPersonId) savedGuest.append(guestOption(options.guestName || "Saved guest", savedPersonId)); + savedGuest.value = savedPersonId; + findGuest.addEventListener("click", async () => { + findGuest.disabled = true; + try { + const query = new URLSearchParams({ active: "true", dnc: "false", limit: "50", search: guestSearch.input.value.trim() }); + const payload = await request(workApiUrl(`/api/sponsor-crm/directory/people?${query}`)); + savedPeople = payload.items || []; + savedGuest.replaceChildren(guestOption("Select a saved guest (optional)", "")); + for (const person of savedPeople) savedGuest.append(guestOption(`${person.name || "Saved guest"}${person.emails?.[0] ? ` (${person.emails[0]})` : " (email needed)"}`, person.id)); + guestHint.textContent = savedPeople.length ? "Select a saved guest to reuse their contact details." : "No saved guests found."; + } catch (error) { guestHint.textContent = `Could not find guests: ${error.message || "request failed"}`; } + finally { findGuest.disabled = false; } + }); + const time = createQuickInput("Time (optional)", "time", options.time || ""); + time.input.dataset.guestTime = ""; + const timeZone = createQuickInput("Schedule time zone", "text", options.timeZone || "Europe/Berlin"); + timeZone.input.dataset.scheduleTimeZone = ""; + const guestTimezone = createQuickInput("Guest time zone (optional)", "text", options.guestTimezone || ""); + guestTimezone.input.dataset.guestTimeZone = ""; + const preferredWindow = createQuickInput("Preferred time window (optional)", "text", options.window || ""); + preferredWindow.input.placeholder = "For example, evening"; + preferredWindow.input.dataset.preferredWindow = ""; + const scheduling = document.createElement("details"); + const schedulingLabel = document.createElement("summary"); + schedulingLabel.textContent = "Scheduling preferences (optional)"; + scheduling.append(schedulingLabel, time.label, timeZone.label, guestTimezone.label, preferredWindow.label); + const skipEmail = createQuickCheckbox("Do not send email", options.skipEmail); + skipEmail.input.dataset.skipEmail = ""; + const guestHint = document.createElement("p"); + guestHint.className = "quick-form-state"; + guestHint.textContent = "With a guest email, DataOps proposes a time and sends the invitation automatically."; + const savedGuestLookup = document.createElement("details"); + const lookupLabel = document.createElement("summary"); + lookupLabel.textContent = "Use a saved guest (optional)"; + savedGuestLookup.append(lookupLabel, guestSearch.label, findGuest, savedGuest); + guestWrap.append(savedGuestLookup, guestEmail.label, guestName.label, topic.label, scheduling, skipEmail.label, guestHint); + guestHint.setAttribute("aria-live", "polite"); const templateControl = { label: selectLabel, input: templateSelect }; const templateState = document.createElement("p"); templateState.className = "quick-form-state"; @@ -188,6 +260,7 @@ export function createQuickTaskActions(context) { groupsWrap, titleInput.label, anchorInput.label, + guestWrap, feedback.node, createBtn, ); @@ -285,13 +358,50 @@ export function createQuickTaskActions(context) { // optional there; any other Template needs one. const selectedTitlePattern = () => liveTemplates.find((template) => template.id === templateSelect.value)?.cardTitle || ""; + const selectedGuestEvent = () => { + const selected = liveTemplates.find((template) => template.id === templateSelect.value); + return selected?.guestIntake?.enabled === true && ["podcast", "webinar", "workshop"].includes(selected.type); + }; + const hasGuestEmail = () => selectedGuestEvent() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail.input.value.trim()); + const hasGuest = () => selectedGuestEvent() && Boolean(guestEmail.input.value.trim() || savedPersonId || guestName.input.value.trim()); + const buttonLabel = () => !hasGuestEmail() ? "Create card" : skipEmail.input.checked ? "Create without email" : "Create and send invitation"; + const persistDraft = () => { + const selected = liveTemplates.find(template => template.id === templateSelect.value); + try { globalThis.sessionStorage?.setItem(draftStorageKey, JSON.stringify({ creationKey, template: selected, title: titleInput.input.value, ...(anchorEdited ? { anchorDate: anchorInput.input.value } : {}), guestEmail: guestEmail.input.value, guestName: guestName.input.value, guestPersonId: savedPersonId, topic: topic.input.value, time: time.input.value, timeZone: timeZone.input.value, guestTimezone: guestTimezone.input.value, window: preferredWindow.input.value, skipEmail: skipEmail.input.checked })); } catch {} + }; + const clearDraft = () => { try { globalThis.sessionStorage?.removeItem(draftStorageKey); } catch {} }; + overlay.onDiscard = clearDraft; + savedGuest.addEventListener("change", () => { + savedPersonId = savedGuest.value; + const person = savedPeople.find(person => person.id === savedPersonId); + if (person) { guestName.input.value = person.name || ""; guestEmail.input.value = person.emails?.[0] || ""; } + syncTitleField(); + persistDraft(); + }); const syncTitleField = () => { - const generated = Boolean(selectedTitlePattern()); + const event = selectedGuestEvent(); + guestWrap.hidden = !event; + for (const control of [guestEmail, guestName, topic, time, timeZone, guestTimezone, preferredWindow, skipEmail]) control.input.disabled = !event; + if (!anchorEdited) anchorInput.input.value = event ? "" : todayIsoDate(); + const generated = Boolean(selectedTitlePattern()) || hasGuest(); titleInput.label.textContent = generated ? "Card title (optional)" : "Card title"; titleInput.label.append(titleInput.input); titleInput.input.setAttribute("aria-required", String(!generated)); titleInput.input.placeholder = generated ? "Generated from the template if left empty" : ""; + anchorInput.label.textContent = hasGuest() ? "Date (optional)" : "Date"; + anchorInput.label.append(anchorInput.input); + anchorInput.input.setAttribute("aria-required", String(!hasGuest())); + guestHint.textContent = skipEmail.input.checked + ? "The event and guest form will be prepared without sending an email or a calendar invite." + : hasGuestEmail() ? `After creating this card, ${guestEmail.input.value.trim()} will automatically receive an invitation email and a [Proposed] calendar invite.` + : "Add an email or choose a saved guest. Without an email, the card waits for contact details."; + if (!cardSubmitInFlight) createBtn.textContent = buttonLabel(); }; + guestEmail.input.addEventListener("input", syncTitleField); + guestName.input.addEventListener("input", syncTitleField); + form.addEventListener("input", persistDraft); + form.addEventListener("change", persistDraft); + skipEmail.input.addEventListener("change", syncTitleField); templateSelect.addEventListener("change", () => { syncOptionalGroups(); syncTitleField(); @@ -310,11 +420,19 @@ export function createQuickTaskActions(context) { if (cardSubmitInFlight) return; const templateId = templateSelect.value; const anchorDate = anchorInput.input.value; - const titleMissing = !titleInput.input.value.trim() && !selectedTitlePattern(); + const email = selectedGuestEvent() ? guestEmail.input.value.trim() : ""; + const guestIntake = hasGuest(); + persistDraft(); + const titleMissing = !titleInput.input.value.trim() && !selectedTitlePattern() && !guestIntake; + const validZone = value => { try { new Intl.DateTimeFormat("en", { timeZone: value }).format(); return true; } catch { return false; } }; const invalid = reportFieldValidation([ + [time, !guestIntake || !time.input.value || anchorDate ? "" : "Choose a date with an explicit time."], + [timeZone, !guestIntake || validZone(timeZone.input.value.trim()) ? "" : "Enter an IANA time zone, for example Europe/Berlin."], + [guestTimezone, !guestIntake || !guestTimezone.input.value.trim() || validZone(guestTimezone.input.value.trim()) ? "" : "Enter an IANA time zone."], [templateControl, templateId ? "" : "Select a template."], [titleInput, templateId && titleMissing ? "Card title is required." : ""], - [anchorInput, anchorDate ? "" : "Date is required."], + [anchorInput, anchorDate || guestIntake ? "" : "Date is required."], + [guestEmail, !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? "" : "Enter a valid guest email."], ]); if (invalid) { feedback.clear(); @@ -328,7 +446,20 @@ export function createQuickTaskActions(context) { feedback.pending("Creating card…"); const routeToken = getActiveWorkspaceRouteToken(); try { - const body = { templateId, anchorDate }; + const body = { templateId }; + if (anchorDate) body.anchorDate = anchorDate; + if (guestIntake) { + if (email) body.guestEmail = email.toLowerCase(); + if (savedPersonId) body.guestPersonId = savedPersonId; + body.creationKey = creationKey; + if (guestName.input.value.trim()) body.guestName = guestName.input.value.trim(); + if (topic.input.value.trim()) body.topic = topic.input.value.trim(); + if (skipEmail.input.checked) body.skipEmail = true; + if (time.input.value) body.time = time.input.value; + if (timeZone.input.value.trim() !== "Europe/Berlin") body.timeZone = timeZone.input.value.trim(); + if (guestTimezone.input.value.trim()) body.guestTimezone = guestTimezone.input.value.trim(); + if (preferredWindow.input.value.trim()) body.window = preferredWindow.input.value.trim(); + } const title = titleInput.input.value.trim(); if (title) body.title = title; if (!groupsWrap.hidden) { @@ -341,6 +472,7 @@ export function createQuickTaskActions(context) { body: JSON.stringify(body), }); const card = result?.card || result; + clearDraft(); overlay.close(); if (!isWorkspaceRouteFresh(routeToken)) { await refreshOperationsWorkSnapshot({ rerender: false }); @@ -371,6 +503,15 @@ export function createQuickTaskActions(context) { options.template = selectedTemplate; options.title = titleInput.input.value; options.anchorDate = anchorDate; + options.guestEmail = guestEmail.input.value; + options.guestName = guestName.input.value; + options.guestPersonId = savedPersonId; + options.topic = topic.input.value; + options.skipEmail = skipEmail.input.checked; + options.time = time.input.value; + options.timeZone = timeZone.input.value; + options.guestTimezone = guestTimezone.input.value; + options.window = preferredWindow.input.value; overlay.close(); openQuickWorkflowForm(options); }, @@ -382,7 +523,7 @@ export function createQuickTaskActions(context) { ) .focus(); } - setControlPending(createBtn, { pending: false, label: "Create card" }); + setControlPending(createBtn, { pending: false, label: buttonLabel() }); } finally { cardSubmitInFlight = false; } @@ -828,7 +969,7 @@ export function createQuickTaskActions(context) { const backdrop = document.createElement("div"); backdrop.className = "confirm-backdrop"; - backdrop.addEventListener("click", () => overlay.close()); + backdrop.addEventListener("click", () => { overlay.onDiscard?.(); overlay.close(); }); const panel = document.createElement("div"); panel.className = "confirm-panel quick-form-panel"; @@ -846,7 +987,7 @@ export function createQuickTaskActions(context) { closeBtn.type = "button"; closeBtn.className = "quiet-button"; closeBtn.textContent = "Close"; - closeBtn.addEventListener("click", () => overlay.close()); + closeBtn.addEventListener("click", () => { overlay.onDiscard?.(); overlay.close(); }); header.append(title, closeBtn); const body = document.createElement("div"); @@ -858,6 +999,7 @@ export function createQuickTaskActions(context) { if (event.defaultPrevented) return; if (event.key === "Escape") { event.preventDefault(); + overlay.onDiscard?.(); overlay.close(); return; } diff --git a/frontend/src/surfaces/work-detail/card-panel.js b/frontend/src/surfaces/work-detail/card-panel.js index bde8bfbb5..d7bb465d4 100644 --- a/frontend/src/surfaces/work-detail/card-panel.js +++ b/frontend/src/surfaces/work-detail/card-panel.js @@ -21,6 +21,7 @@ import { appendBreakableText, createCardSummary, renderCardNewsletterIssue, + renderCardGuestInvitation, renderCardTemplateLink, } from "./card-summary.js"; import { renderCardEventTime, renderCardInvitesSection } from "./card-invites.js"; @@ -401,6 +402,8 @@ export function createCardPanel(context) { ), ); main.append(meta); + const invitation = renderCardGuestInvitation({ request, workApiUrl, reload: reloadCardIntent }, card); + if (invitation) main.append(invitation); main.append(renderCardNewsletterIssue({ request, workApiUrl, reload: reloadCardIntent }, card)); const materialsAction = renderWebinarMaterialsAction({ request, workApiUrl, reload: reloadCardIntent }, card, state.workSnapshot.templates); if (materialsAction) main.append(materialsAction); diff --git a/frontend/src/surfaces/work-detail/card-summary.js b/frontend/src/surfaces/work-detail/card-summary.js index 15978548a..de4f170b1 100644 --- a/frontend/src/surfaces/work-detail/card-summary.js +++ b/frontend/src/surfaces/work-detail/card-summary.js @@ -633,3 +633,110 @@ function renderCardOverflowMenu(card, busy, deleteCardPanel, getCurrentOperator) wrap.append(trigger, menu); return wrap; } + +// Delivery receipts are evidence: a queued job or a sent status without its +// provider receipt must never be presented as an invitation delivered. +export function renderCardGuestInvitation({ request, workApiUrl, reload, watch = true, schedule = setTimeout, cancel = clearTimeout }, card) { + const intake = card?.guestIntake; + if (!intake) return null; + const section = document.createElement("section"); + section.className = "workflow-detail-section"; + section.dataset.guestInvitation = ""; + const heading = document.createElement("strong"); + const sent = intake.status === "sent" && Boolean(intake.messageId); + heading.textContent = sent ? "Invitation sent" + : intake.status === "skipped" ? "Invitation not sent" + : intake.status === "contact-pending" ? "Guest contact details needed" + : intake.status === "failed" ? "Invitation could not be sent" : "Invitation preparing"; + section.append(heading); + if (intake.recipient) { + const recipient = document.createElement("p"); + recipient.textContent = `Guest: ${intake.recipient}`; + section.append(recipient); + } + if (intake.startIso) { + const time = document.createElement("p"); + const parsed = new Date(intake.startIso); + time.textContent = `Proposed time: ${Number.isNaN(parsed.getTime()) ? intake.startIso : new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", timeZone: "Europe/Berlin" }).format(parsed)} (Berlin)`; + section.append(time); + } + if (sent && intake.sentAt) { + const delivered = document.createElement("p"); + const timestamp = new Date(intake.sentAt); + delivered.dataset.invitationSentAt = ""; + delivered.textContent = `Sent at: ${Number.isNaN(timestamp.getTime()) ? intake.sentAt : new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin" }).format(timestamp)} (Berlin)`; + section.append(delivered); + } + if (sent) { + const mail = document.createElement("a"); + mail.href = `https://mail.google.com/mail/u/orin@datatalks.club/#all/${encodeURIComponent(intake.messageId)}`; + mail.textContent = "View sent invitation"; + opensInNewTab(mail); + section.append(mail); + } + if (intake.status === "failed" || intake.status === "contact-pending") { + if (intake.error) { + const message = document.createElement("p"); + message.textContent = typeof intake.error === "string" ? intake.error : intake.error.message || "Check the linked task for details."; + section.append(message); + } + if (intake.taskId) { + const task = document.createElement("a"); + task.href = `/tasks?taskId=${encodeURIComponent(intake.taskId)}`; + task.textContent = "Open invitation task"; + section.append(task); + } + } + if (intake.status === "skipped") { + const button = document.createElement("button"); + button.type = "button"; + button.className = "task-action-btn"; + button.textContent = "Send invitation"; + const feedback = document.createElement("p"); + feedback.setAttribute("role", "status"); + button.addEventListener("click", async () => { + if (button.disabled) return; + button.disabled = true; + feedback.textContent = "Preparing invitation…"; + try { + await request(workApiUrl(`/api/cards/${encodeURIComponent(card.id)}/guest-intake/send`), { method: "POST", body: "{}" }); + button.remove(); + heading.textContent = "Invitation preparing"; + feedback.textContent = ""; + await reload?.(); + } catch (error) { + feedback.textContent = `Could not prepare invitation: ${error.message || "request failed"}`; + button.disabled = false; + } + }); + section.append(button, feedback); + } + if (watch && ["queued", "preparing"].includes(intake.status)) { + let timer; + let attempts = 0; + let stopped = false; + let observer; + const stop = () => { stopped = true; if (timer) cancel(timer); observer?.disconnect(); }; + const poll = async () => { + if (stopped || !section.isConnected || ++attempts > 40) { stop(); return; } + try { + const payload = await request(workApiUrl(`/api/cards/${encodeURIComponent(card.id)}`)); + if (stopped || !section.isConnected) { stop(); return; } + const next = payload?.card || payload; + if (next?.guestIntake) { + const receipt = renderCardGuestInvitation({ request, workApiUrl, reload, watch: false }, next); + section.replaceChildren(...receipt.childNodes); + if (!["queued", "preparing"].includes(next.guestIntake.status)) { stop(); return; } + } + } catch { /* Retain the last actual receipt and try within the bounded window. */ } + if (!stopped) { timer = schedule(poll, 3000); timer?.unref?.(); } + }; + timer = schedule(poll, 3000); + timer?.unref?.(); + if (typeof MutationObserver !== "undefined") { + observer = new MutationObserver(() => { if (!section.isConnected) stop(); }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + } + } + return section; +} diff --git a/frontend/test/guest-invitation.test.mjs b/frontend/test/guest-invitation.test.mjs new file mode 100644 index 000000000..809034218 --- /dev/null +++ b/frontend/test/guest-invitation.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { FakeDocument, findByText } from './support/fake-dom.mjs'; +import { renderCardGuestInvitation } from '../src/surfaces/work-detail/card-summary.js'; +const originalDocument = globalThis.document; +afterEach(() => { globalThis.document = originalDocument; }); +const render = (intake, context = {}) => { + globalThis.document = new FakeDocument(); + return renderCardGuestInvitation({ request: async () => { throw new Error('No request expected'); }, workApiUrl: path => path, ...context }, { id: 'synthetic-card', guestIntake: intake }); +}; +test('receipt requires provider message evidence before displaying sent', () => { + for (const status of ['queued', 'preparing', 'sent']) { + const section = render({ status, recipient: 'guest@example.test' }); + assert.match(section.textContent, /Invitation preparing/); + assert.doesNotMatch(section.textContent, /Invitation sent/); + assert.equal(section.querySelector('button'), null); + } + const section = render({ status: 'sent', messageId: 'synthetic-message', sentAt: '2030-11-01T09:15:00Z', recipient: 'guest@example.test', startIso: '2030-12-02T12:00:00Z' }); + assert.match(section.textContent, /Invitation sent/); + assert.match(section.textContent, /Proposed time:/); + assert.match(section.querySelector('[data-invitation-sent-at]').textContent, /1 Nov 2030.*10:15/); + assert.match(section.querySelector('a').href, /synthetic-message$/); +}); +test('failure and missing contact point to their Task, never send automatically on render', () => { + for (const status of ['failed', 'contact-pending']) { + const section = render({ status, taskId: 'synthetic-task', error: 'Contact details need attention' }); + assert.match(section.querySelector('a').href, /taskId=synthetic-task/); + assert.equal(section.querySelector('button'), null); + } + assert.equal(render(undefined), null); +}); +test('only a skipped first invitation exposes send action; clicking queues once', async () => { + const calls = []; + let reloads = 0; + const section = render({ status: 'skipped' }, { request: async (...args) => { calls.push(args); }, reload: async () => { reloads++; } }); + const button = findByText(section, 'Send invitation', 'button'); + await button.click(); + await button.click(); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], '/api/cards/synthetic-card/guest-intake/send'); + assert.equal(calls[0][1].method, 'POST'); + assert.equal(reloads, 1); + assert.match(section.textContent, /Invitation preparing/); + assert.doesNotMatch(section.textContent, /Invitation sent/); +}); +test('preparing receipt refreshes to sent or failed and stops after terminal evidence', async () => { + for (const status of ['sent', 'failed']) { + const scheduled = []; + let requests = 0; + const section = render({ status: 'queued' }, { + schedule: callback => { scheduled.push(callback); return scheduled.length; }, cancel: () => {}, + request: async () => { requests++; return { card: { id: 'synthetic-card', guestIntake: { status, ...(status === 'sent' ? { messageId: 'receipt-id' } : { taskId: 'failed-task', error: 'Delivery failed' }) } } }; }, + }); + assert.equal(scheduled.length, 1); + await scheduled[0](); + assert.equal(requests, 1); + assert.equal(scheduled.length, 1); + assert.match(section.textContent, status === 'sent' ? /Invitation sent/ : /Invitation could not be sent/); + } +}); +test('closing the Card stops pending invitation checks without another request', async () => { + const scheduled = []; + let requests = 0; + const section = render({ status: 'preparing' }, { schedule: callback => { scheduled.push(callback); return 1; }, cancel: () => {}, request: async () => { requests++; } }); + section.remove(); + await scheduled[0](); + assert.equal(requests, 0); +}); diff --git a/frontend/test/sponsor-doc-event-time.test.mjs b/frontend/test/sponsor-doc-event-time.test.mjs new file mode 100644 index 000000000..ab7126c27 --- /dev/null +++ b/frontend/test/sponsor-doc-event-time.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { dualZoneEventTime, formatGuestEventTime, guestEventTimeZone } from '../src/surfaces/operations/sponsor-doc-event-time.js'; +test('dual-zone labels carry both full local dates across midnight and duration', () => { + const label = dualZoneEventTime('2030-12-02T18:00:00Z', '2030-12-02T19:00:00Z', 'Asia/Kolkata'); + assert.match(label, /Mon, 2 Dec 2030/); + assert.match(label, /Tue, 3 Dec 2030/); + assert.match(label, /19:00/); + assert.match(label, /00:30/); + assert.match(label, /60 minutes/); + assert.match(label, /Europe\/Berlin/); + assert.match(label, /Asia\/Kolkata/); +}); +test('formatting uses IANA DST offsets at each instant and rejects non-ISO guesses', () => { + assert.match(formatGuestEventTime('2030-03-31T00:30:00Z', '2030-03-31T01:30:00Z', 'Europe/Berlin'), /01:30.*03:30.*60 minutes/); + assert.equal(dualZoneEventTime(undefined, undefined, 'UTC'), ''); + assert.equal(guestEventTimeZone('Asia/Kolkata'), 'Asia/Kolkata'); +}); diff --git a/frontend/test/sponsor-docs.test.mjs b/frontend/test/sponsor-docs.test.mjs index 1d1ca2864..560363fae 100644 --- a/frontend/test/sponsor-docs.test.mjs +++ b/frontend/test/sponsor-docs.test.mjs @@ -700,3 +700,40 @@ test("event fields render read-only and are excluded from document saves", async const put = h.calls.find(([, opts]) => opts.method === "PUT"); assert.deepEqual(JSON.parse(put[1].body).content, { goal: "Authored goal" }); }); + + +test("guest portal shows dual zones and saves a time choice before required materials", async () => { + const definition = podcastTemplateDefinition(); + definition.sections[0].fields[1].options[1] = { value: "slot:2030-12-03:evening", label: "Canonical choice", startIso: "2030-12-03T18:00:00Z", endIso: "2030-12-03T19:00:00Z" }; + definition.sections[1].fields[1].required = true; + definition.sections[1].fields.push({ key: "photoUrl", label: "Photo", type: "text", allowVisualUpload: true, maxCharsHint: 300000 }); + const h = harness({ href: "https://ops.example.test/podcast-docs/doc-pod?code=bcdfghjklmnpqrst", pathname: "/podcast-docs/doc-pod", docOverrides: { + template: "podcast", templateDefinition: definition, + eventMetadata: { label: "Canonical Berlin time", startIso: "2030-12-02T18:00:00Z", endIso: "2030-12-02T19:00:00Z", durationMinutes: 60, version: 1 }, + content: { guestTimezone: "Asia/Kolkata" }, + } }); + await h.controller.start(); + assert.match(h.root.querySelector('[data-sponsor-doc-event]').textContent, /Tue, 3 Dec 2030/); + assert.equal(h.root.querySelector('[data-sponsor-field-group="photoUrl"] .sponsor-doc-field__count').hidden, true); + assert.equal(h.root.querySelector("#sponsor-field-proposedSlotLabel"), null); + const zone = h.root.querySelector('[data-guest-timezone]'); + zone.value = "America/New_York"; await zone.dispatch("change"); + assert.match(h.root.querySelector('[data-sponsor-doc-event]').textContent, /America\/New_York/); + const radios = h.root.querySelectorAll('[data-sponsor-radios="slotChoice"] input'); + await radios[1].dispatch("change"); + assert.match(h.root.querySelector('[data-sponsor-radio-option="slot:2030-12-03:evening"]').textContent, /Tue, 3 Dec 2030/); + await h.root.querySelector('[data-sponsor-action="save-time-choice"]').click(); + await nextTicks(); + const putCall = h.calls.find(([, options]) => options.method === "PUT"); + const body = JSON.parse(putCall[1].body); + assert.equal(body.content.slotChoice, "slot:2030-12-03:evening"); + assert.equal(body.content.guestTimezone, "America/New_York"); + assert.equal(body.status, undefined); + assert.equal(body.content.introduction, undefined); + assert.deepEqual(body.timeChoiceIntent, { choice: "slot:2030-12-03:evening", proposalStartIso: "2030-12-02T18:00:00Z", proposalVersion: 1 }); + await h.root.querySelector('[data-sponsor-action="save-draft"]').click(); + await nextTicks(); + const ordinarySave = h.calls.filter(([, options]) => options.method === "PUT").at(-1); + assert.equal(JSON.parse(ordinarySave[1].body).timeChoiceIntent, undefined, "ordinary biography/material saves do not reconfirm the stored slot choice"); + h.controller.destroy(); +}); diff --git a/frontend/test/tasks-surface.test.mjs b/frontend/test/tasks-surface.test.mjs index 141e113c2..ba006c1a9 100644 --- a/frontend/test/tasks-surface.test.mjs +++ b/frontend/test/tasks-surface.test.mjs @@ -3992,7 +3992,7 @@ describe("Tasks surface boundary", () => { const overlay = findAllByClass(harness.shellBody, "quick-form-overlay")[0]; overlay.querySelector("select").value = "tpl-news"; await overlay.querySelector("select").dispatch("change"); - const boxes = overlay.querySelectorAll('input[type="checkbox"]'); + const boxes = overlay.querySelectorAll('.quick-form-groups input[type="checkbox"]'); assert.equal(boxes.length, 1); assert.match(boxes[0].parentElement.textContent, /secondary/); boxes[0].checked = true; @@ -4456,4 +4456,166 @@ describe("Tasks surface boundary", () => { assert.equal(retainedInputs[0].value, "August newsletter"); assert.equal(retainedInputs[1].value, "2026-08-20"); }); + + test("guest event Card creation accepts email alone and keeps one key after a lost response", async () => { + const submissions = []; + const harness = createHarness({ request: async (url, options = {}) => { + if (url === "/api/templates") return { templates: [{ id: "event-template", type: "podcast", name: "Guest session", guestIntake: { enabled: true } }] }; + if (url === "/api/cards") { + submissions.push(JSON.parse(options.body)); + if (submissions.length === 1) throw new Error("Synthetic lost response"); + return { card: { id: "guest-card" } }; + } + throw new Error(`Unexpected request ${url}`); + } }); + await harness.api.openQuickWorkflowForm(); + const overlay = findAllByClass(harness.shellBody, "quick-form-overlay")[0]; + const select = overlay.querySelector("select"); + select.value = "event-template"; + await select.dispatch("change"); + assert.equal(overlay.querySelector('input[type="date"]').value, ""); + const email = overlay.querySelector('[data-guest-email]'); + email.value = "Guest@Example.test"; + await email.dispatch("input"); + assert.equal(overlay.querySelector('input[type="date"]').getAttribute("aria-required"), "false"); + await submitQuickForm(overlay); + await nextTicks(); + assert.equal(submissions.length, 1); + const first = submissions[0]; + assert.equal(first.guestEmail, "guest@example.test"); + assert.ok(first.creationKey); + for (const field of ["title", "anchorDate", "guestName", "topic", "skipEmail"]) assert.ok(!(field in first)); + assert.match(overlay.textContent, /Synthetic lost response/); + await submitQuickForm(overlay); + await nextTicks(); + assert.deepEqual(submissions[1], first); + assert.equal(overlay.removed, true); + assert.deepEqual(harness.openedCards, ["guest-card"]); + }); + + test("guest creation preserves an explicit date and skip-email when reviewing a conflict", async () => { + const submissions = []; + const harness = createHarness({ request: async (url, options = {}) => { + if (url === "/api/templates") return { templates: [{ id: "event-policy", name: "Guest session", type: "webinar", guestIntake: { enabled: true } }] }; + if (url === "/api/cards") { + submissions.push(JSON.parse(options.body)); + throw Object.assign(new Error("Synthetic conflict"), { status: 409 }); + } + throw new Error(`Unexpected request ${url}`); + } }); + await harness.api.openQuickWorkflowForm(); + const overlay = findAllByClass(harness.shellBody, "quick-form-overlay")[0]; + const select = overlay.querySelector("select"); + select.value = "event-policy"; + await select.dispatch("change"); + const date = overlay.querySelector('input[type="date"]'); + date.value = "2026-12-14"; + await date.dispatch("input"); + const email = overlay.querySelector('[data-guest-email]'); + email.value = "guest@example.test"; + await email.dispatch("input"); + overlay.querySelector('[data-guest-name]').value = "Synthetic Guest"; + overlay.querySelector('[data-guest-topic]').value = "Practical workflows"; + overlay.querySelector('[data-skip-email]').checked = true; + await submitQuickForm(overlay); + await nextTicks(); + const first = submissions[0]; + assert.equal(first.anchorDate, "2026-12-14"); + assert.equal(first.skipEmail, true); + assert.equal(first.guestName, "Synthetic Guest"); + await findByText(overlay, "Review latest Templates", "button").click(); + await nextTicks(3); + const reopened = findAllByClass(harness.shellBody, "quick-form-overlay").at(-1); + assert.equal(reopened.querySelector('[data-skip-email]').checked, true); + assert.equal(reopened.querySelector('[data-guest-email]').value, "guest@example.test"); + await submitQuickForm(reopened); + await nextTicks(); + assert.deepEqual(submissions[1], first); + }); + test("guest creation discloses automatic delivery and supports saved contacts across reload", async () => { + const oldStorage = globalThis.sessionStorage; + const storage = new Map(); + globalThis.sessionStorage = { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key) }; + try { + const submissions = []; + const harness = createHarness({ request: async (url, options = {}) => { + if (url === "/api/templates") return { templates: [{ id: "podcast", type: "podcast", guestIntake: { enabled: true } }] }; + if (url.startsWith("/api/sponsor-crm/directory/people?")) return { items: [{ id: "saved-contact", name: "Saved Guest", emails: ["saved@example.test"] }] }; + if (url === "/api/cards") { submissions.push(JSON.parse(options.body)); throw new Error("Synthetic lost response"); } + throw new Error(`Unexpected request ${url}`); + } }); + await harness.api.openQuickWorkflowForm(); + let overlay = findAllByClass(harness.shellBody, "quick-form-overlay").at(-1); + const select = overlay.querySelector("select"); select.value = "podcast"; await select.dispatch("change"); + await findByText(overlay, "Find guest", "button").click(); + const saved = overlay.querySelector('[data-saved-guest]'); saved.value = "saved-contact"; await saved.dispatch("change"); + assert.match(overlay.textContent, /saved@example.test will automatically receive an invitation email and a \[Proposed\] calendar invite/); + assert.ok(findByText(overlay, "Create and send invitation", "button")); + const skip = overlay.querySelector('[data-skip-email]'); skip.checked = true; await skip.dispatch("change"); + assert.ok(findByText(overlay, "Create without email", "button")); + await submitQuickForm(overlay); await nextTicks(); + assert.equal(submissions[0].guestPersonId, "saved-contact"); + overlay.remove(); // Browser reload does not explicitly discard the draft. + await harness.api.openQuickWorkflowForm(); + overlay = findAllByClass(harness.shellBody, "quick-form-overlay").at(-1); + assert.equal(overlay.querySelector('[data-guest-email]').value, "saved@example.test"); + await submitQuickForm(overlay); await nextTicks(); + assert.deepEqual(submissions[1], submissions[0]); + await findByText(overlay, "Close", "button").click(); + assert.equal(storage.size, 0); + } finally { globalThis.sessionStorage = oldStorage; } + }); + + test("unapproved event types expose no automatic invitation fields", async () => { + const harness = createHarness({ request: async () => ({ templates: [{ id: "other", type: "office-hours", guestIntake: { enabled: true }, eventPolicy: {} }] }) }); + await harness.api.openQuickWorkflowForm(); + const overlay = findAllByClass(harness.shellBody, "quick-form-overlay").at(-1); + const select = overlay.querySelector("select"); select.value = "other"; await select.dispatch("change"); + assert.equal(overlay.querySelector('[data-guest-event-fields]').hidden, true); + assert.equal(overlay.querySelector('[data-guest-email]').disabled, true); + }); + + test("guest scheduling passes explicit local clock time and both time zones", async () => { + const calls = []; + const harness = createHarness({ request: async (url, options = {}) => { + if (url === "/api/templates") return { templates: [{ id: "workshop", type: "workshop", guestIntake: { enabled: true } }] }; + calls.push(JSON.parse(options.body)); return { card: { id: "scheduled-card" } }; + } }); + await harness.api.openQuickWorkflowForm({ template: { id: "workshop" }, anchorDate: "2030-12-02", guestEmail: "guest@example.test" }); + const overlay = findAllByClass(harness.shellBody, "quick-form-overlay").at(-1); + overlay.querySelector('[data-guest-time]').value = "18:30"; + overlay.querySelector('[data-schedule-time-zone]').value = "America/New_York"; + overlay.querySelector('[data-guest-time-zone]').value = "Asia/Kolkata"; + overlay.querySelector('[data-preferred-window]').value = "evening"; + await submitQuickForm(overlay); await nextTicks(); + assert.equal(calls[0].time, "18:30"); assert.equal(calls[0].timeZone, "America/New_York"); + assert.equal(calls[0].guestTimezone, "Asia/Kolkata"); assert.equal(calls[0].window, "evening"); + assert.equal(calls[0].anchorDate, "2030-12-02"); + }); + + test("changing the recipient after an unknown create outcome keeps its key and surfaces conflict", async () => { + const submissions = []; + const harness = createHarness({ request: async (url, options = {}) => { + if (url === "/api/templates") return { templates: [{ id: "podcast", type: "podcast", guestIntake: { enabled: true } }] }; + if (url === "/api/cards") { + submissions.push(JSON.parse(options.body)); + if (submissions.length === 1) throw new Error("Synthetic unknown network outcome"); + throw Object.assign(new Error("creationKey already belongs to a different Card request"), { status: 409 }); + } + throw new Error(`Unexpected request ${url}`); + } }); + await harness.api.openQuickWorkflowForm({ template: { id: "podcast" }, guestEmail: "first@example.test" }); + const overlay = findAllByClass(harness.shellBody, "quick-form-overlay").at(-1); + await submitQuickForm(overlay); await nextTicks(); + const email = overlay.querySelector('[data-guest-email]'); + email.value = "second@example.test"; await email.dispatch("input"); + await submitQuickForm(overlay); await nextTicks(); + assert.equal(submissions.length, 2); + assert.equal(submissions[0].creationKey, submissions[1].creationKey); + assert.equal(submissions[1].guestEmail, "second@example.test"); + assert.match(overlay.textContent, /creationKey already belongs to a different Card request/); + assert.equal(overlay.querySelector('[data-guest-email]').value, "second@example.test"); + assert.equal(overlay.removed, false); + }); + }); diff --git a/infra/template.full.yaml b/infra/template.full.yaml index 16dd4a21a..81040f377 100644 --- a/infra/template.full.yaml +++ b/infra/template.full.yaml @@ -569,6 +569,8 @@ Resources: Properties: TableName: !Sub ${AWS::StackName}-cards BillingMode: PAY_PER_REQUEST + StreamSpecification: + StreamViewType: NEW_IMAGE DeletionProtectionEnabled: true SSESpecification: SSEEnabled: true @@ -1090,6 +1092,27 @@ Resources: RedrivePolicy: deadLetterTargetArn: !GetAtt CardBannerDeadLetterQueue.Arn maxReceiveCount: 3 + GuestEventIntakeDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + SqsManagedSseEnabled: true + MessageRetentionPeriod: 1209600 + + GuestEventIntakeDeadLetterAlarm: + Type: AWS::CloudWatch::Alarm + Properties: + AlarmName: !Sub ${AWS::StackName}-guest-event-intake-dead-letter + Namespace: AWS/SQS + MetricName: ApproximateNumberOfMessagesVisible + Dimensions: [{Name: QueueName, Value: !GetAtt GuestEventIntakeDeadLetterQueue.QueueName}] + Statistic: Maximum + Period: 300 + EvaluationPeriods: 1 + Threshold: 1 + ComparisonOperator: GreaterThanOrEqualToThreshold + TreatMissingData: notBreaching + AlarmActions: [!Ref ConversationalRolloutAlarmTopic] + BackendFunction: Type: AWS::Serverless::Function Metadata: @@ -1107,6 +1130,14 @@ Resources: LogFormat: JSON LogGroup: !Ref ConversationalBackendLogGroup Policies: + - Statement: + Effect: Allow + Action: [dynamodb:GetRecords, dynamodb:GetShardIterator, dynamodb:DescribeStream, dynamodb:ListStreams] + Resource: !GetAtt DataOpsCardsTable.StreamArn + - Statement: + Effect: Allow + Action: [sqs:SendMessage] + Resource: !GetAtt GuestEventIntakeDeadLetterQueue.Arn - Statement: Effect: Allow Action: s3:ListBucket @@ -1342,6 +1373,34 @@ Resources: SPONSOR_COMMUNICATION_TEMPLATE_SECRET_ARN: !Ref SponsorCommunicationTemplateSecretArn SPONSOR_COMMUNICATION_HMAC_SECRET_ARN: !Ref SponsorCommunicationHmacSecretArn Events: + GuestEventCardCreated: + Type: DynamoDB + Properties: + Stream: !GetAtt DataOpsCardsTable.StreamArn + StartingPosition: LATEST + BatchSize: 1 + MaximumRecordAgeInSeconds: 86400 + MaximumRetryAttempts: -1 + DestinationConfig: + OnFailure: + Type: SQS + Destination: !GetAtt GuestEventIntakeDeadLetterQueue.Arn + FilterCriteria: + Filters: + - Pattern: '{"eventName":["INSERT"],"dynamodb":{"NewImage":{"guestIntake":{"M":{"jobId":{"S":[{"exists":true}]}}}}}}' + GuestEventIntakeResume: + Type: Schedule + Properties: + Schedule: rate(2 minutes) + Enabled: true + Input: '{"source":"aws.events","detail":{"dataopsAction":"guest-event-intake-resume"}}' + GuestEventAcceptancePoll: + Type: Schedule + Properties: + Schedule: rate(2 minutes) + Enabled: true + Input: '{"source":"aws.events","detail":{"dataopsAction":"guest-event-acceptance-poll"}}' + CardBannerJobs: Type: SQS Properties: From 6e73ba04ccdefd286ceaaff38b507b58dc081fa0 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:26:40 +0100 Subject: [PATCH 02/10] Extract guest intake form helpers and clarify paused receipts --- .../operations/sponsor-doc-guest-schedule.js | 64 +++ .../src/surfaces/operations/sponsor-docs.js | 70 +-- .../src/surfaces/tasks/quick-card-create.js | 469 ++++++++++++++++++ frontend/src/surfaces/tasks/quick-create.js | 445 +---------------- .../src/surfaces/work-detail/card-summary.js | 16 +- frontend/test/guest-invitation.test.mjs | 14 + 6 files changed, 592 insertions(+), 486 deletions(-) create mode 100644 frontend/src/surfaces/operations/sponsor-doc-guest-schedule.js create mode 100644 frontend/src/surfaces/tasks/quick-card-create.js diff --git a/frontend/src/surfaces/operations/sponsor-doc-guest-schedule.js b/frontend/src/surfaces/operations/sponsor-doc-guest-schedule.js new file mode 100644 index 000000000..aa0cf344e --- /dev/null +++ b/frontend/src/surfaces/operations/sponsor-doc-guest-schedule.js @@ -0,0 +1,64 @@ +import { dualZoneEventTime, guestEventTimeZone } from "./sponsor-doc-event-time.js"; + +export function appendGuestTimezoneSelector({ el, headerCard, guestZone, busy, draftContent, render }) { + const zoneLabel = el("label", "sponsor-doc-field__label", "Your time zone"); + const zoneSelect = el("select", "dk-input"); + zoneSelect.dataset.guestTimezone = ""; + zoneSelect.setAttribute("aria-label", "Your time zone"); + const zones = new Set([guestZone, "Europe/Berlin", "UTC", ...(Intl.supportedValuesOf?.("timeZone") || [])]); + for (const zone of zones) { const option = el("option", "", zone); option.value = zone; zoneSelect.append(option); } + zoneSelect.value = guestZone; + zoneSelect.disabled = busy; + zoneSelect.addEventListener("change", () => { draftContent.guestTimezone = zoneSelect.value; render(); }); + zoneLabel.append(zoneSelect); + headerCard.append(zoneLabel, el("p", "dk-hint", + "Times show the Berlin schedule and your local date and time. " + + "You can save a time choice now and complete the questionnaire later.")); +} + +export function guestSlotFieldDefinition(fieldDef, metadata, guestZone) { + if (fieldDef.key !== "slotChoice") return fieldDef; + return { ...fieldDef, options: (fieldDef.options || []).map(option => { + const proposed = option.value === "confirm_proposed"; + const start = option.startIso || (proposed ? metadata.startIso : undefined); + const end = option.endIso || (proposed ? metadata.endIso : undefined); + const label = dualZoneEventTime(start, end, guestZone, metadata.durationMinutes); + return label ? { ...option, label: `${proposed ? "Confirm proposed time: " : ""}${label}` } : option; + }) }; +} + +export function appendTimeChoiceSave({ fieldWrap, actionBtn, busy, draftContent, metadata, saveChanges, showWarning }) { + fieldWrap.append(actionBtn("dk-button dk-button--secondary", busy ? "Saving…" : "Save time choice", "save-time-choice", () => { + if (!draftContent.slotChoice) { + showWarning("Choose a time option first."); + return; + } + saveChanges(undefined, { + choice: draftContent.slotChoice, + proposalStartIso: metadata.startIso, + proposalVersion: metadata.version, + }); + }, busy)); +} + +export function guestEventMetadataRow({ el, doc, tDef, draftContent, isSpeakerDoc, isPodcastGuestDoc, formatEditorLabel }) { + const metaRow = el("div", "sponsor-doc-meta"); + metaRow.append(el("span", "dk-badge", tDef.label || doc.template)); + const guestSchedule = ["podcast", "webinar", "workshop"].includes(doc.template) && doc.eventMetadata?.startIso; + const guestZone = guestEventTimeZone(draftContent.guestTimezone || doc.eventMetadata?.guestTimezone); + if (guestSchedule && !draftContent.guestTimezone) draftContent.guestTimezone = guestZone; + if (doc.eventMetadata) { + const eventLabel = el("span", "", guestSchedule + ? dualZoneEventTime(doc.eventMetadata.startIso, doc.eventMetadata.endIso, guestZone, doc.eventMetadata.durationMinutes) + : doc.eventMetadata.label); + eventLabel.dataset.sponsorDocEvent = ""; + eventLabel.style.whiteSpace = "pre-line"; + metaRow.append(eventLabel); + } + if (doc.publicationDate) metaRow.append(el("span", "", `Publication date: ${doc.publicationDate}`)); + if (doc.sponsorName) metaRow.append(el("span", "", `${isSpeakerDoc(doc) ? "Speaker" : isPodcastGuestDoc(doc) ? "Guest" : "Sponsor"}: ${doc.sponsorName}`)); + const editedSpan = el("span", "", formatEditorLabel(doc)); + editedSpan.dataset.sponsorDocLastEdited = ""; + metaRow.append(editedSpan); + return { metaRow, guestSchedule, guestZone }; +} diff --git a/frontend/src/surfaces/operations/sponsor-docs.js b/frontend/src/surfaces/operations/sponsor-docs.js index f010c7199..9ff3ce9fc 100644 --- a/frontend/src/surfaces/operations/sponsor-docs.js +++ b/frontend/src/surfaces/operations/sponsor-docs.js @@ -1,4 +1,4 @@ -import { guestEventTimeZone, dualZoneEventTime } from "./sponsor-doc-event-time.js"; +import { appendGuestTimezoneSelector, appendTimeChoiceSave, guestSlotFieldDefinition, guestEventMetadataRow } from "./sponsor-doc-guest-schedule.js"; import { formatUserCode } from "../../dakit/device-code.js"; import { renderSponsorDocGate } from "./sponsor-doc-gate.js"; import { appendSponsorDocRadioField } from "./sponsor-doc-radio.js"; @@ -402,39 +402,14 @@ export function createSponsorDocPage({ badge.dataset.sponsorDocStatus = doc.status; titleRow.append(el("h1", "sponsor-doc-title", formTitle), badge); - const metaRow = el("div", "sponsor-doc-meta"); - metaRow.append(el("span", "dk-badge", tDef.label || doc.template)); - const guestSchedule = ["podcast", "webinar", "workshop"].includes(doc.template) && doc.eventMetadata?.startIso; - const guestZone = guestEventTimeZone(draftContent.guestTimezone || doc.eventMetadata?.guestTimezone); - if (guestSchedule && !draftContent.guestTimezone) draftContent.guestTimezone = guestZone; - if (doc.eventMetadata) { - const eventLabel = el("span", "", guestSchedule - ? dualZoneEventTime(doc.eventMetadata.startIso, doc.eventMetadata.endIso, guestZone, doc.eventMetadata.durationMinutes) - : doc.eventMetadata.label); - eventLabel.dataset.sponsorDocEvent = ""; - eventLabel.style.whiteSpace = "pre-line"; - metaRow.append(eventLabel); - } - if (doc.publicationDate) metaRow.append(el("span", "", `Publication date: ${doc.publicationDate}`)); - if (doc.sponsorName) metaRow.append(el("span", "", `${isSpeakerDoc(doc) ? "Speaker" : isPodcastGuestDoc(doc) ? "Guest" : "Sponsor"}: ${doc.sponsorName}`)); - const editedSpan = el("span", "", formatEditorLabel(doc)); - editedSpan.dataset.sponsorDocLastEdited = ""; - metaRow.append(editedSpan); + const { metaRow, guestSchedule, guestZone } = guestEventMetadataRow({ + el, doc, tDef, draftContent, isSpeakerDoc, isPodcastGuestDoc, formatEditorLabel, + }); headWrap.append(titleRow, metaRow); headerCard.append(headWrap); - if (guestSchedule) { - const zoneLabel = el("label", "sponsor-doc-field__label", "Your time zone"); - const zoneSelect = el("select", "dk-input"); - zoneSelect.dataset.guestTimezone = ""; - zoneSelect.setAttribute("aria-label", "Your time zone"); - const zones = new Set([guestZone, "Europe/Berlin", "UTC", ...(Intl.supportedValuesOf?.("timeZone") || [])]); - for (const zone of zones) { const option = el("option", "", zone); option.value = zone; zoneSelect.append(option); } - zoneSelect.value = guestZone; - zoneSelect.disabled = busy; - zoneSelect.addEventListener("change", () => { draftContent.guestTimezone = zoneSelect.value; render(); }); - zoneLabel.append(zoneSelect); - headerCard.append(zoneLabel, el("p", "dk-hint", "Times show the Berlin schedule and your local date and time. You can save a time choice now and complete the questionnaire later.")); - } + if (guestSchedule) appendGuestTimezoneSelector({ + el, headerCard, guestZone, busy, draftContent, render, + }); if (doc.viewerRole === "staff") { const staffBar = el("div", "sponsor-doc-staff-bar"); @@ -716,15 +691,9 @@ export function createSponsorDocPage({ appendSponsorDocRadioField({ el, fieldWrap, - fieldDef: fieldDef.key === "slotChoice" && guestSchedule ? { - ...fieldDef, - options: (fieldDef.options || []).map(option => { - const start = option.startIso || (option.value === "confirm_proposed" ? doc.eventMetadata.startIso : undefined); - const end = option.endIso || (option.value === "confirm_proposed" ? doc.eventMetadata.endIso : undefined); - const label = dualZoneEventTime(start, end, guestZone, doc.eventMetadata.durationMinutes); - return label ? { ...option, label: `${option.value === "confirm_proposed" ? "Confirm proposed time: " : ""}${label}` } : option; - }), - } : fieldDef, + fieldDef: guestSchedule + ? guestSlotFieldDefinition(fieldDef, doc.eventMetadata, guestZone) + : fieldDef, inputId, currentVal, busy, @@ -732,17 +701,14 @@ export function createSponsorDocPage({ updateLivePreview, top, }); - if (fieldDef.key === "slotChoice" && guestSchedule) { - fieldWrap.append(actionBtn("dk-button dk-button--secondary", busy ? "Saving…" : "Save time choice", "save-time-choice", () => { - if (!draftContent.slotChoice) { - feedbackMsg = "Choose a time option first."; - feedbackTone = "warning"; - render(); - return; - } - saveChanges(undefined, { choice: draftContent.slotChoice, proposalStartIso: doc.eventMetadata.startIso, proposalVersion: doc.eventMetadata.version }); - }, busy)); - } + if (fieldDef.key === "slotChoice" && guestSchedule) appendTimeChoiceSave({ + fieldWrap, actionBtn, busy, draftContent, metadata: doc.eventMetadata, + saveChanges, showWarning: message => { + feedbackMsg = message; + feedbackTone = "warning"; + render(); + }, + }); appendErrorIfAny(); appendFieldNode(fieldDef.key, fieldWrap); continue; diff --git a/frontend/src/surfaces/tasks/quick-card-create.js b/frontend/src/surfaces/tasks/quick-card-create.js new file mode 100644 index 000000000..27eb15afc --- /dev/null +++ b/frontend/src/surfaces/tasks/quick-card-create.js @@ -0,0 +1,469 @@ +import { createFormFeedback, reportFieldValidation, setControlPending } from "../operations-overview.js"; + +export function createQuickCardForm(context) { + const { + createQuickInput, + createQuickCheckbox, + createQuickFormOverlay, + addConflictReviewAction, + getActiveWorkspaceRouteToken, + isWorkspaceRouteFresh, + openCardPanel, + refreshOperationsWorkSnapshot, + request, + todayIsoDate, + workApiUrl, + } = context; + async function openQuickWorkflowForm(options = {}) { + const draftStorageKey = "dataops.guest-card-draft"; + let savedDraft = {}; + try { savedDraft = JSON.parse(globalThis.sessionStorage?.getItem(draftStorageKey) || "{}"); } catch {} + options = { ...savedDraft, ...options }; + const requestedTemplate = options.template || null; + const creationKey = options.creationKey || globalThis.crypto?.randomUUID?.() + || `card-${Date.now()}-${Math.random().toString(36).slice(2)}`; + options.creationKey = creationKey; + const overlay = createQuickFormOverlay("Create card"); + const form = document.createElement("form"); + form.className = "quick-form"; + form.addEventListener("submit", (event) => event.preventDefault()); + + const selectLabel = document.createElement("label"); + selectLabel.className = "quick-form-label"; + selectLabel.textContent = "Template"; + const templateSelect = document.createElement("select"); + templateSelect.className = "quick-form-select"; + const loadingOpt = document.createElement("option"); + loadingOpt.value = ""; + loadingOpt.textContent = "Loading templates..."; + templateSelect.append(loadingOpt); + templateSelect.disabled = true; + selectLabel.append(templateSelect); + + const anchorInput = createQuickInput( + "Date", + "date", + options.anchorDate ?? todayIsoDate(), + ); + let anchorEdited = Object.hasOwn(options, "anchorDate"); + anchorInput.input.addEventListener("input", () => { anchorEdited = true; }); + anchorInput.input.addEventListener("change", () => { anchorEdited = true; }); + const titleInput = createQuickInput("Card title", "text", options.title || ""); + const guestWrap = document.createElement("div"); + guestWrap.hidden = true; + guestWrap.className = "quick-form"; + guestWrap.dataset.guestEventFields = ""; + const guestEmail = createQuickInput("Guest email", "email", options.guestEmail || ""); + guestEmail.input.dataset.guestEmail = ""; + const guestName = createQuickInput("Guest name (optional)", "text", options.guestName || ""); + guestName.input.dataset.guestName = ""; + const topic = createQuickInput("Topic (optional)", "text", options.topic || ""); + topic.input.dataset.guestTopic = ""; + const guestSearch = createQuickInput("Find a saved guest", "search", ""); + guestSearch.input.dataset.guestSearch = ""; + const findGuest = document.createElement("button"); + findGuest.type = "button"; + findGuest.className = "quiet-button"; + findGuest.textContent = "Find guest"; + const guestOption = (label, value) => { + const option = document.createElement("option"); + option.textContent = label; + option.value = value; + return option; + }; + const savedGuest = document.createElement("select"); + savedGuest.dataset.savedGuest = ""; + savedGuest.className = "quick-form-select"; + savedGuest.setAttribute("aria-label", "Saved guest"); + savedGuest.append(guestOption("Select a saved guest (optional)", "")); + let savedPeople = []; + let savedPersonId = options.guestPersonId || options.guestContactId || ""; + if (savedPersonId) savedGuest.append(guestOption(options.guestName || "Saved guest", savedPersonId)); + savedGuest.value = savedPersonId; + findGuest.addEventListener("click", async () => { + findGuest.disabled = true; + try { + const query = new URLSearchParams({ active: "true", dnc: "false", limit: "50", search: guestSearch.input.value.trim() }); + const payload = await request(workApiUrl(`/api/sponsor-crm/directory/people?${query}`)); + savedPeople = payload.items || []; + savedGuest.replaceChildren(guestOption("Select a saved guest (optional)", "")); + for (const person of savedPeople) savedGuest.append(guestOption(`${person.name || "Saved guest"}${person.emails?.[0] ? ` (${person.emails[0]})` : " (email needed)"}`, person.id)); + guestHint.textContent = savedPeople.length ? "Select a saved guest to reuse their contact details." : "No saved guests found."; + } catch (error) { guestHint.textContent = `Could not find guests: ${error.message || "request failed"}`; } + finally { findGuest.disabled = false; } + }); + const time = createQuickInput("Time (optional)", "time", options.time || ""); + time.input.dataset.guestTime = ""; + const timeZone = createQuickInput("Schedule time zone", "text", options.timeZone || "Europe/Berlin"); + timeZone.input.dataset.scheduleTimeZone = ""; + const guestTimezone = createQuickInput("Guest time zone (optional)", "text", options.guestTimezone || ""); + guestTimezone.input.dataset.guestTimeZone = ""; + const preferredWindow = createQuickInput("Preferred time window (optional)", "text", options.window || ""); + preferredWindow.input.placeholder = "For example, evening"; + preferredWindow.input.dataset.preferredWindow = ""; + const scheduling = document.createElement("details"); + const schedulingLabel = document.createElement("summary"); + schedulingLabel.textContent = "Scheduling preferences (optional)"; + scheduling.append(schedulingLabel, time.label, timeZone.label, guestTimezone.label, preferredWindow.label); + const skipEmail = createQuickCheckbox("Do not send email", options.skipEmail); + skipEmail.input.dataset.skipEmail = ""; + const guestHint = document.createElement("p"); + guestHint.className = "quick-form-state"; + guestHint.textContent = "With a guest email, DataOps proposes a time and sends the invitation automatically."; + const savedGuestLookup = document.createElement("details"); + const lookupLabel = document.createElement("summary"); + lookupLabel.textContent = "Use a saved guest (optional)"; + savedGuestLookup.append(lookupLabel, guestSearch.label, findGuest, savedGuest); + guestWrap.append(savedGuestLookup, guestEmail.label, guestName.label, topic.label, scheduling, skipEmail.label, guestHint); + guestHint.setAttribute("aria-live", "polite"); + const templateControl = { label: selectLabel, input: templateSelect }; + const templateState = document.createElement("p"); + templateState.className = "quick-form-state"; + templateState.setAttribute("role", "status"); + templateState.setAttribute("aria-live", "polite"); + templateState.textContent = "Loading templates…"; + const groupsWrap = document.createElement("div"); + groupsWrap.className = "quick-form-groups"; + groupsWrap.hidden = true; + const feedback = createFormFeedback(); + + const createBtn = document.createElement("button"); + createBtn.type = "submit"; + createBtn.className = "task-action-btn is-primary"; + createBtn.textContent = "Create card"; + createBtn.disabled = true; + let cardSubmitInFlight = false; + + form.append( + selectLabel, + templateState, + groupsWrap, + titleInput.label, + anchorInput.label, + guestWrap, + feedback.node, + createBtn, + ); + overlay.querySelector(".quick-form-body").append(form); + titleInput.input.focus(); + titleInput.input.focus(); + + // Fetch live templates from the backend API (UUIDs, not doc slugs) + let liveTemplates = []; + try { + const payload = await request(workApiUrl("/api/templates")); + liveTemplates = Array.isArray(payload) + ? payload + : payload.templates || []; + } catch (error) { + templateSelect.replaceChildren(); + const unavailable = document.createElement("option"); + unavailable.value = ""; + unavailable.textContent = "Templates unavailable"; + templateSelect.append(unavailable); + templateSelect.disabled = true; + createBtn.disabled = true; + // The form stays open and offers its own recovery: a card cannot be + // created without templates, but the operator does not lose the dialog. + feedback + .failure( + `Templates could not be loaded: ${error.message || "request failed"}`, + ) + .focus(); + templateState.textContent = "No templates are available to select."; + const retry = document.createElement("button"); + retry.type = "button"; + retry.className = "quiet-button quick-form-retry"; + retry.textContent = "Retry loading templates"; + retry.addEventListener("click", () => { + overlay.close(); + openQuickWorkflowForm(options); + }); + feedback.node.append(retry); + return; + } + + templateSelect.replaceChildren(); + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select template..."; + templateSelect.append(placeholder); + for (const template of liveTemplates) { + const opt = document.createElement("option"); + opt.value = template.id; + opt.textContent = template.name || template.title || template.id; + templateSelect.append(opt); + } + const matchedTemplate = findLiveWorkflowTemplate( + liveTemplates, + requestedTemplate, + ); + if (matchedTemplate?.id) { + templateSelect.value = matchedTemplate.id; + if (!titleInput.input.value && requestedTemplate?.title) + titleInput.input.value = requestedTemplate.title; + } + templateSelect.disabled = false; + templateState.textContent = + liveTemplates.length > 0 + ? "" + : "No templates are deployed, so no card can be created yet."; + // Optional task groups (e.g. a secondary sponsor block) instantiate + // only when checked. Unchecked groups stay out of the new Card. + const syncOptionalGroups = () => { + const selected = liveTemplates.find( + (template) => template.id === templateSelect.value, + ); + const groups = [ + ...new Set( + (selected?.taskDefinitions || []) + .map((definition) => definition?.optionalGroup) + .filter((group) => typeof group === "string" && group.length > 0), + ), + ]; + groupsWrap.replaceChildren(); + groupsWrap.hidden = groups.length === 0; + for (const group of groups) { + const label = document.createElement("label"); + label.className = "quick-form-label quick-form-checkbox"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.value = group; + box.dataset.optionalGroup = group; + label.append(box, document.createTextNode(group === "sponsored" ? "Sponsored" : `Include ${group} tasks`)); + groupsWrap.append(label); + } + }; + // A Template with a card_title pattern names its Cards, so the title is + // optional there; any other Template needs one. + const selectedTitlePattern = () => + liveTemplates.find((template) => template.id === templateSelect.value)?.cardTitle || ""; + const selectedGuestEvent = () => { + const selected = liveTemplates.find((template) => template.id === templateSelect.value); + return selected?.guestIntake?.enabled === true && ["podcast", "webinar", "workshop"].includes(selected.type); + }; + const hasGuestEmail = () => selectedGuestEvent() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail.input.value.trim()); + const hasGuest = () => selectedGuestEvent() && Boolean(guestEmail.input.value.trim() || savedPersonId || guestName.input.value.trim()); + const buttonLabel = () => !hasGuestEmail() ? "Create card" : skipEmail.input.checked ? "Create without email" : "Create and send invitation"; + const persistDraft = () => { + const selected = liveTemplates.find(template => template.id === templateSelect.value); + try { + globalThis.sessionStorage?.setItem(draftStorageKey, JSON.stringify({ + creationKey, + template: selected, + title: titleInput.input.value, + ...(anchorEdited ? { anchorDate: anchorInput.input.value } : {}), + guestEmail: guestEmail.input.value, + guestName: guestName.input.value, + guestPersonId: savedPersonId, + topic: topic.input.value, + time: time.input.value, + timeZone: timeZone.input.value, + guestTimezone: guestTimezone.input.value, + window: preferredWindow.input.value, + skipEmail: skipEmail.input.checked, + })); + } catch {} + }; + const clearDraft = () => { try { globalThis.sessionStorage?.removeItem(draftStorageKey); } catch {} }; + overlay.onDiscard = clearDraft; + savedGuest.addEventListener("change", () => { + savedPersonId = savedGuest.value; + const person = savedPeople.find(person => person.id === savedPersonId); + if (person) { guestName.input.value = person.name || ""; guestEmail.input.value = person.emails?.[0] || ""; } + syncTitleField(); + persistDraft(); + }); + const syncTitleField = () => { + const event = selectedGuestEvent(); + guestWrap.hidden = !event; + for (const control of [guestEmail, guestName, topic, time, timeZone, guestTimezone, preferredWindow, skipEmail]) control.input.disabled = !event; + if (!anchorEdited) anchorInput.input.value = event ? "" : todayIsoDate(); + const generated = Boolean(selectedTitlePattern()) || hasGuest(); + titleInput.label.textContent = generated ? "Card title (optional)" : "Card title"; + titleInput.label.append(titleInput.input); + titleInput.input.setAttribute("aria-required", String(!generated)); + titleInput.input.placeholder = generated ? "Generated from the template if left empty" : ""; + anchorInput.label.textContent = hasGuest() ? "Date (optional)" : "Date"; + anchorInput.label.append(anchorInput.input); + anchorInput.input.setAttribute("aria-required", String(!hasGuest())); + guestHint.textContent = skipEmail.input.checked + ? "The event and guest form will be prepared without sending an email or a calendar invite." + : hasGuestEmail() ? `After creating this card, ${guestEmail.input.value.trim()} will automatically receive an invitation email and a [Proposed] calendar invite.` + : "Add an email or choose a saved guest. Without an email, the card waits for contact details."; + if (!cardSubmitInFlight) createBtn.textContent = buttonLabel(); + }; + guestEmail.input.addEventListener("input", syncTitleField); + guestName.input.addEventListener("input", syncTitleField); + form.addEventListener("input", persistDraft); + form.addEventListener("change", persistDraft); + skipEmail.input.addEventListener("change", syncTitleField); + templateSelect.addEventListener("change", () => { + syncOptionalGroups(); + syncTitleField(); + }); + syncOptionalGroups(); + syncTitleField(); + if (liveTemplates.length > 0) createBtn.disabled = false; + else { + const emptyOpt = document.createElement("option"); + emptyOpt.textContent = "No templates available"; + templateSelect.append(emptyOpt); + templateSelect.disabled = true; + } + + const submitCard = async () => { + if (cardSubmitInFlight) return; + const templateId = templateSelect.value; + const anchorDate = anchorInput.input.value; + const email = selectedGuestEvent() ? guestEmail.input.value.trim() : ""; + const guestIntake = hasGuest(); + persistDraft(); + const titleMissing = !titleInput.input.value.trim() && !selectedTitlePattern() && !guestIntake; + const validZone = value => { try { new Intl.DateTimeFormat("en", { timeZone: value }).format(); return true; } catch { return false; } }; + const invalid = reportFieldValidation([ + [time, !guestIntake || !time.input.value || anchorDate ? "" : "Choose a date with an explicit time."], + [timeZone, !guestIntake || validZone(timeZone.input.value.trim()) ? "" : "Enter an IANA time zone, for example Europe/Berlin."], + [guestTimezone, !guestIntake || !guestTimezone.input.value.trim() || validZone(guestTimezone.input.value.trim()) ? "" : "Enter an IANA time zone."], + [templateControl, templateId ? "" : "Select a template."], + [titleInput, templateId && titleMissing ? "Card title is required." : ""], + [anchorInput, anchorDate || guestIntake ? "" : "Date is required."], + [guestEmail, !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? "" : "Enter a valid guest email."], + ]); + if (invalid) { + feedback.clear(); + return; + } + cardSubmitInFlight = true; + setControlPending(createBtn, { + pending: true, + pendingLabel: "Creating card…", + }); + feedback.pending("Creating card…"); + const routeToken = getActiveWorkspaceRouteToken(); + try { + const body = { templateId }; + if (anchorDate) body.anchorDate = anchorDate; + if (guestIntake) { + if (email) body.guestEmail = email.toLowerCase(); + if (savedPersonId) body.guestPersonId = savedPersonId; + body.creationKey = creationKey; + if (guestName.input.value.trim()) body.guestName = guestName.input.value.trim(); + if (topic.input.value.trim()) body.topic = topic.input.value.trim(); + if (skipEmail.input.checked) body.skipEmail = true; + if (time.input.value) body.time = time.input.value; + if (timeZone.input.value.trim() !== "Europe/Berlin") body.timeZone = timeZone.input.value.trim(); + if (guestTimezone.input.value.trim()) body.guestTimezone = guestTimezone.input.value.trim(); + if (preferredWindow.input.value.trim()) body.window = preferredWindow.input.value.trim(); + } + const title = titleInput.input.value.trim(); + if (title) body.title = title; + if (!groupsWrap.hidden) { + body.optionalGroups = [...groupsWrap.querySelectorAll("input[type=\"checkbox\"]")] + .filter((box) => box.checked) + .map((box) => box.value); + } + const result = await request(workApiUrl("/api/cards"), { + method: "POST", + body: JSON.stringify(body), + }); + const card = result?.card || result; + clearDraft(); + overlay.close(); + if (!isWorkspaceRouteFresh(routeToken)) { + await refreshOperationsWorkSnapshot({ rerender: false }); + return; + } + if (card?.id) openCardPanel(card.id); + await refreshOperationsWorkSnapshot({ rerender: true }); + } catch (err) { + if (!isWorkspaceRouteFresh(routeToken)) { + overlay.close(); + return; + } + if (err.status === 409 || err.code === "template_version_conflict") { + feedback + .conflict( + `The Template changed since this form was opened: ${err.message || "version conflict"}` + + ` The entries in this draft are retained. Select Create card to retry,` + + ` Close to discard this draft, or Review latest Templates to load the current list.`, + ) + .focus(); + addConflictReviewAction( + feedback, + "Review latest Templates", + () => { + const selectedTemplate = + liveTemplates.find((template) => template.id === templateId) || + requestedTemplate; + options.template = selectedTemplate; + options.title = titleInput.input.value; + options.anchorDate = anchorDate; + options.guestEmail = guestEmail.input.value; + options.guestName = guestName.input.value; + options.guestPersonId = savedPersonId; + options.topic = topic.input.value; + options.skipEmail = skipEmail.input.checked; + options.time = time.input.value; + options.timeZone = timeZone.input.value; + options.guestTimezone = guestTimezone.input.value; + options.window = preferredWindow.input.value; + overlay.close(); + openQuickWorkflowForm(options); + }, + ); + } else { + feedback + .failure( + `Could not create card: ${err.message || "request failed"} Select Create card to retry.`, + ) + .focus(); + } + setControlPending(createBtn, { pending: false, label: buttonLabel() }); + } finally { + cardSubmitInFlight = false; + } + }; + form.addEventListener("submit", submitCard); + } + + function findLiveWorkflowTemplate(liveTemplates, requestedTemplate) { + if (!requestedTemplate || !Array.isArray(liveTemplates)) return null; + const wantedId = normalizeTemplateMatchValue( + requestedTemplate.id || + requestedTemplate.templateId || + requestedTemplate.sourceTemplateId || + requestedTemplate.canonicalTemplateId, + ); + if (wantedId) { + const idMatches = liveTemplates.filter( + (template) => normalizeTemplateMatchValue(template.id) === wantedId, + ); + return idMatches.length === 1 ? idMatches[0] : null; + } + + const wantedSlug = normalizeTemplateMatchValue( + requestedTemplate.slug || requestedTemplate.type, + ); + if (!wantedSlug) return null; + const slugMatches = liveTemplates.filter((template) => { + const templateSlug = normalizeTemplateMatchValue( + template.slug || template.type, + ); + return templateSlug === wantedSlug; + }); + return slugMatches.length === 1 ? slugMatches[0] : null; + } + + function normalizeTemplateMatchValue(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+task template$/i, "") + .replace(/&/g, "and") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + } + + return openQuickWorkflowForm; +} diff --git a/frontend/src/surfaces/tasks/quick-create.js b/frontend/src/surfaces/tasks/quick-create.js index 1dd9c75a2..84be2dfcd 100644 --- a/frontend/src/surfaces/tasks/quick-create.js +++ b/frontend/src/surfaces/tasks/quick-create.js @@ -1,3 +1,4 @@ +import { createQuickCardForm } from "./quick-card-create.js"; import { createBrowserRequirementSelect } from "./browser-requirement.js"; import { createProcessDocPicker } from "./process-doc-picker.js"; import { @@ -29,6 +30,20 @@ export function createQuickTaskActions(context) { workApiUrl, } = context; + const openQuickWorkflowForm = createQuickCardForm({ + createQuickInput, + createQuickCheckbox, + createQuickFormOverlay, + addConflictReviewAction, + getActiveWorkspaceRouteToken, + isWorkspaceRouteFresh, + openCardPanel, + refreshOperationsWorkSnapshot, + request, + todayIsoDate, + workApiUrl, + }); + function addConflictReviewAction(feedback, label, onReview) { for (const stale of feedback.node.querySelectorAll(".quick-form-retry")) stale.remove(); @@ -139,436 +154,6 @@ export function createQuickTaskActions(context) { descInput.input.focus(); } - async function openQuickWorkflowForm(options = {}) { - const draftStorageKey = "dataops.guest-card-draft"; - let savedDraft = {}; - try { savedDraft = JSON.parse(globalThis.sessionStorage?.getItem(draftStorageKey) || "{}"); } catch {} - options = { ...savedDraft, ...options }; - const requestedTemplate = options.template || null; - const creationKey = options.creationKey || globalThis.crypto?.randomUUID?.() - || `card-${Date.now()}-${Math.random().toString(36).slice(2)}`; - options.creationKey = creationKey; - const overlay = createQuickFormOverlay("Create card"); - const form = document.createElement("form"); - form.className = "quick-form"; - form.addEventListener("submit", (event) => event.preventDefault()); - - const selectLabel = document.createElement("label"); - selectLabel.className = "quick-form-label"; - selectLabel.textContent = "Template"; - const templateSelect = document.createElement("select"); - templateSelect.className = "quick-form-select"; - const loadingOpt = document.createElement("option"); - loadingOpt.value = ""; - loadingOpt.textContent = "Loading templates..."; - templateSelect.append(loadingOpt); - templateSelect.disabled = true; - selectLabel.append(templateSelect); - - const anchorInput = createQuickInput( - "Date", - "date", - options.anchorDate ?? todayIsoDate(), - ); - let anchorEdited = Object.hasOwn(options, "anchorDate"); - anchorInput.input.addEventListener("input", () => { anchorEdited = true; }); - anchorInput.input.addEventListener("change", () => { anchorEdited = true; }); - const titleInput = createQuickInput("Card title", "text", options.title || ""); - const guestWrap = document.createElement("div"); - guestWrap.hidden = true; - guestWrap.className = "quick-form"; - guestWrap.dataset.guestEventFields = ""; - const guestEmail = createQuickInput("Guest email", "email", options.guestEmail || ""); - guestEmail.input.dataset.guestEmail = ""; - const guestName = createQuickInput("Guest name (optional)", "text", options.guestName || ""); - guestName.input.dataset.guestName = ""; - const topic = createQuickInput("Topic (optional)", "text", options.topic || ""); - topic.input.dataset.guestTopic = ""; - const guestSearch = createQuickInput("Find a saved guest", "search", ""); - guestSearch.input.dataset.guestSearch = ""; - const findGuest = document.createElement("button"); - findGuest.type = "button"; - findGuest.className = "quiet-button"; - findGuest.textContent = "Find guest"; - const guestOption = (label, value) => { const option = document.createElement("option"); option.textContent = label; option.value = value; return option; }; - const savedGuest = document.createElement("select"); - savedGuest.dataset.savedGuest = ""; - savedGuest.className = "quick-form-select"; - savedGuest.setAttribute("aria-label", "Saved guest"); - savedGuest.append(guestOption("Select a saved guest (optional)", "")); - let savedPeople = []; - let savedPersonId = options.guestPersonId || options.guestContactId || ""; - if (savedPersonId) savedGuest.append(guestOption(options.guestName || "Saved guest", savedPersonId)); - savedGuest.value = savedPersonId; - findGuest.addEventListener("click", async () => { - findGuest.disabled = true; - try { - const query = new URLSearchParams({ active: "true", dnc: "false", limit: "50", search: guestSearch.input.value.trim() }); - const payload = await request(workApiUrl(`/api/sponsor-crm/directory/people?${query}`)); - savedPeople = payload.items || []; - savedGuest.replaceChildren(guestOption("Select a saved guest (optional)", "")); - for (const person of savedPeople) savedGuest.append(guestOption(`${person.name || "Saved guest"}${person.emails?.[0] ? ` (${person.emails[0]})` : " (email needed)"}`, person.id)); - guestHint.textContent = savedPeople.length ? "Select a saved guest to reuse their contact details." : "No saved guests found."; - } catch (error) { guestHint.textContent = `Could not find guests: ${error.message || "request failed"}`; } - finally { findGuest.disabled = false; } - }); - const time = createQuickInput("Time (optional)", "time", options.time || ""); - time.input.dataset.guestTime = ""; - const timeZone = createQuickInput("Schedule time zone", "text", options.timeZone || "Europe/Berlin"); - timeZone.input.dataset.scheduleTimeZone = ""; - const guestTimezone = createQuickInput("Guest time zone (optional)", "text", options.guestTimezone || ""); - guestTimezone.input.dataset.guestTimeZone = ""; - const preferredWindow = createQuickInput("Preferred time window (optional)", "text", options.window || ""); - preferredWindow.input.placeholder = "For example, evening"; - preferredWindow.input.dataset.preferredWindow = ""; - const scheduling = document.createElement("details"); - const schedulingLabel = document.createElement("summary"); - schedulingLabel.textContent = "Scheduling preferences (optional)"; - scheduling.append(schedulingLabel, time.label, timeZone.label, guestTimezone.label, preferredWindow.label); - const skipEmail = createQuickCheckbox("Do not send email", options.skipEmail); - skipEmail.input.dataset.skipEmail = ""; - const guestHint = document.createElement("p"); - guestHint.className = "quick-form-state"; - guestHint.textContent = "With a guest email, DataOps proposes a time and sends the invitation automatically."; - const savedGuestLookup = document.createElement("details"); - const lookupLabel = document.createElement("summary"); - lookupLabel.textContent = "Use a saved guest (optional)"; - savedGuestLookup.append(lookupLabel, guestSearch.label, findGuest, savedGuest); - guestWrap.append(savedGuestLookup, guestEmail.label, guestName.label, topic.label, scheduling, skipEmail.label, guestHint); - guestHint.setAttribute("aria-live", "polite"); - const templateControl = { label: selectLabel, input: templateSelect }; - const templateState = document.createElement("p"); - templateState.className = "quick-form-state"; - templateState.setAttribute("role", "status"); - templateState.setAttribute("aria-live", "polite"); - templateState.textContent = "Loading templates…"; - const groupsWrap = document.createElement("div"); - groupsWrap.className = "quick-form-groups"; - groupsWrap.hidden = true; - const feedback = createFormFeedback(); - - const createBtn = document.createElement("button"); - createBtn.type = "submit"; - createBtn.className = "task-action-btn is-primary"; - createBtn.textContent = "Create card"; - createBtn.disabled = true; - let cardSubmitInFlight = false; - - form.append( - selectLabel, - templateState, - groupsWrap, - titleInput.label, - anchorInput.label, - guestWrap, - feedback.node, - createBtn, - ); - overlay.querySelector(".quick-form-body").append(form); - titleInput.input.focus(); - titleInput.input.focus(); - - // Fetch live templates from the backend API (UUIDs, not doc slugs) - let liveTemplates = []; - try { - const payload = await request(workApiUrl("/api/templates")); - liveTemplates = Array.isArray(payload) - ? payload - : payload.templates || []; - } catch (error) { - templateSelect.replaceChildren(); - const unavailable = document.createElement("option"); - unavailable.value = ""; - unavailable.textContent = "Templates unavailable"; - templateSelect.append(unavailable); - templateSelect.disabled = true; - createBtn.disabled = true; - // The form stays open and offers its own recovery: a card cannot be - // created without templates, but the operator does not lose the dialog. - feedback - .failure( - `Templates could not be loaded: ${error.message || "request failed"}`, - ) - .focus(); - templateState.textContent = "No templates are available to select."; - const retry = document.createElement("button"); - retry.type = "button"; - retry.className = "quiet-button quick-form-retry"; - retry.textContent = "Retry loading templates"; - retry.addEventListener("click", () => { - overlay.close(); - openQuickWorkflowForm(options); - }); - feedback.node.append(retry); - return; - } - - templateSelect.replaceChildren(); - const placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.textContent = "Select template..."; - templateSelect.append(placeholder); - for (const template of liveTemplates) { - const opt = document.createElement("option"); - opt.value = template.id; - opt.textContent = template.name || template.title || template.id; - templateSelect.append(opt); - } - const matchedTemplate = findLiveWorkflowTemplate( - liveTemplates, - requestedTemplate, - ); - if (matchedTemplate?.id) { - templateSelect.value = matchedTemplate.id; - if (!titleInput.input.value && requestedTemplate?.title) - titleInput.input.value = requestedTemplate.title; - } - templateSelect.disabled = false; - templateState.textContent = - liveTemplates.length > 0 - ? "" - : "No templates are deployed, so no card can be created yet."; - // Optional task groups (e.g. a secondary sponsor block) instantiate - // only when checked. Unchecked groups stay out of the new Card. - const syncOptionalGroups = () => { - const selected = liveTemplates.find( - (template) => template.id === templateSelect.value, - ); - const groups = [ - ...new Set( - (selected?.taskDefinitions || []) - .map((definition) => definition?.optionalGroup) - .filter((group) => typeof group === "string" && group.length > 0), - ), - ]; - groupsWrap.replaceChildren(); - groupsWrap.hidden = groups.length === 0; - for (const group of groups) { - const label = document.createElement("label"); - label.className = "quick-form-label quick-form-checkbox"; - const box = document.createElement("input"); - box.type = "checkbox"; - box.value = group; - box.dataset.optionalGroup = group; - label.append(box, document.createTextNode(group === "sponsored" ? "Sponsored" : `Include ${group} tasks`)); - groupsWrap.append(label); - } - }; - // A Template with a card_title pattern names its Cards, so the title is - // optional there; any other Template needs one. - const selectedTitlePattern = () => - liveTemplates.find((template) => template.id === templateSelect.value)?.cardTitle || ""; - const selectedGuestEvent = () => { - const selected = liveTemplates.find((template) => template.id === templateSelect.value); - return selected?.guestIntake?.enabled === true && ["podcast", "webinar", "workshop"].includes(selected.type); - }; - const hasGuestEmail = () => selectedGuestEvent() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail.input.value.trim()); - const hasGuest = () => selectedGuestEvent() && Boolean(guestEmail.input.value.trim() || savedPersonId || guestName.input.value.trim()); - const buttonLabel = () => !hasGuestEmail() ? "Create card" : skipEmail.input.checked ? "Create without email" : "Create and send invitation"; - const persistDraft = () => { - const selected = liveTemplates.find(template => template.id === templateSelect.value); - try { globalThis.sessionStorage?.setItem(draftStorageKey, JSON.stringify({ creationKey, template: selected, title: titleInput.input.value, ...(anchorEdited ? { anchorDate: anchorInput.input.value } : {}), guestEmail: guestEmail.input.value, guestName: guestName.input.value, guestPersonId: savedPersonId, topic: topic.input.value, time: time.input.value, timeZone: timeZone.input.value, guestTimezone: guestTimezone.input.value, window: preferredWindow.input.value, skipEmail: skipEmail.input.checked })); } catch {} - }; - const clearDraft = () => { try { globalThis.sessionStorage?.removeItem(draftStorageKey); } catch {} }; - overlay.onDiscard = clearDraft; - savedGuest.addEventListener("change", () => { - savedPersonId = savedGuest.value; - const person = savedPeople.find(person => person.id === savedPersonId); - if (person) { guestName.input.value = person.name || ""; guestEmail.input.value = person.emails?.[0] || ""; } - syncTitleField(); - persistDraft(); - }); - const syncTitleField = () => { - const event = selectedGuestEvent(); - guestWrap.hidden = !event; - for (const control of [guestEmail, guestName, topic, time, timeZone, guestTimezone, preferredWindow, skipEmail]) control.input.disabled = !event; - if (!anchorEdited) anchorInput.input.value = event ? "" : todayIsoDate(); - const generated = Boolean(selectedTitlePattern()) || hasGuest(); - titleInput.label.textContent = generated ? "Card title (optional)" : "Card title"; - titleInput.label.append(titleInput.input); - titleInput.input.setAttribute("aria-required", String(!generated)); - titleInput.input.placeholder = generated ? "Generated from the template if left empty" : ""; - anchorInput.label.textContent = hasGuest() ? "Date (optional)" : "Date"; - anchorInput.label.append(anchorInput.input); - anchorInput.input.setAttribute("aria-required", String(!hasGuest())); - guestHint.textContent = skipEmail.input.checked - ? "The event and guest form will be prepared without sending an email or a calendar invite." - : hasGuestEmail() ? `After creating this card, ${guestEmail.input.value.trim()} will automatically receive an invitation email and a [Proposed] calendar invite.` - : "Add an email or choose a saved guest. Without an email, the card waits for contact details."; - if (!cardSubmitInFlight) createBtn.textContent = buttonLabel(); - }; - guestEmail.input.addEventListener("input", syncTitleField); - guestName.input.addEventListener("input", syncTitleField); - form.addEventListener("input", persistDraft); - form.addEventListener("change", persistDraft); - skipEmail.input.addEventListener("change", syncTitleField); - templateSelect.addEventListener("change", () => { - syncOptionalGroups(); - syncTitleField(); - }); - syncOptionalGroups(); - syncTitleField(); - if (liveTemplates.length > 0) createBtn.disabled = false; - else { - const emptyOpt = document.createElement("option"); - emptyOpt.textContent = "No templates available"; - templateSelect.append(emptyOpt); - templateSelect.disabled = true; - } - - const submitCard = async () => { - if (cardSubmitInFlight) return; - const templateId = templateSelect.value; - const anchorDate = anchorInput.input.value; - const email = selectedGuestEvent() ? guestEmail.input.value.trim() : ""; - const guestIntake = hasGuest(); - persistDraft(); - const titleMissing = !titleInput.input.value.trim() && !selectedTitlePattern() && !guestIntake; - const validZone = value => { try { new Intl.DateTimeFormat("en", { timeZone: value }).format(); return true; } catch { return false; } }; - const invalid = reportFieldValidation([ - [time, !guestIntake || !time.input.value || anchorDate ? "" : "Choose a date with an explicit time."], - [timeZone, !guestIntake || validZone(timeZone.input.value.trim()) ? "" : "Enter an IANA time zone, for example Europe/Berlin."], - [guestTimezone, !guestIntake || !guestTimezone.input.value.trim() || validZone(guestTimezone.input.value.trim()) ? "" : "Enter an IANA time zone."], - [templateControl, templateId ? "" : "Select a template."], - [titleInput, templateId && titleMissing ? "Card title is required." : ""], - [anchorInput, anchorDate || guestIntake ? "" : "Date is required."], - [guestEmail, !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? "" : "Enter a valid guest email."], - ]); - if (invalid) { - feedback.clear(); - return; - } - cardSubmitInFlight = true; - setControlPending(createBtn, { - pending: true, - pendingLabel: "Creating card…", - }); - feedback.pending("Creating card…"); - const routeToken = getActiveWorkspaceRouteToken(); - try { - const body = { templateId }; - if (anchorDate) body.anchorDate = anchorDate; - if (guestIntake) { - if (email) body.guestEmail = email.toLowerCase(); - if (savedPersonId) body.guestPersonId = savedPersonId; - body.creationKey = creationKey; - if (guestName.input.value.trim()) body.guestName = guestName.input.value.trim(); - if (topic.input.value.trim()) body.topic = topic.input.value.trim(); - if (skipEmail.input.checked) body.skipEmail = true; - if (time.input.value) body.time = time.input.value; - if (timeZone.input.value.trim() !== "Europe/Berlin") body.timeZone = timeZone.input.value.trim(); - if (guestTimezone.input.value.trim()) body.guestTimezone = guestTimezone.input.value.trim(); - if (preferredWindow.input.value.trim()) body.window = preferredWindow.input.value.trim(); - } - const title = titleInput.input.value.trim(); - if (title) body.title = title; - if (!groupsWrap.hidden) { - body.optionalGroups = [...groupsWrap.querySelectorAll("input[type=\"checkbox\"]")] - .filter((box) => box.checked) - .map((box) => box.value); - } - const result = await request(workApiUrl("/api/cards"), { - method: "POST", - body: JSON.stringify(body), - }); - const card = result?.card || result; - clearDraft(); - overlay.close(); - if (!isWorkspaceRouteFresh(routeToken)) { - await refreshOperationsWorkSnapshot({ rerender: false }); - return; - } - if (card?.id) openCardPanel(card.id); - await refreshOperationsWorkSnapshot({ rerender: true }); - } catch (err) { - if (!isWorkspaceRouteFresh(routeToken)) { - overlay.close(); - return; - } - if (err.status === 409 || err.code === "template_version_conflict") { - feedback - .conflict( - `The Template changed since this form was opened: ${err.message || "version conflict"}` + - ` The entries in this draft are retained. Select Create card to retry,` + - ` Close to discard this draft, or Review latest Templates to load the current list.`, - ) - .focus(); - addConflictReviewAction( - feedback, - "Review latest Templates", - () => { - const selectedTemplate = - liveTemplates.find((template) => template.id === templateId) || - requestedTemplate; - options.template = selectedTemplate; - options.title = titleInput.input.value; - options.anchorDate = anchorDate; - options.guestEmail = guestEmail.input.value; - options.guestName = guestName.input.value; - options.guestPersonId = savedPersonId; - options.topic = topic.input.value; - options.skipEmail = skipEmail.input.checked; - options.time = time.input.value; - options.timeZone = timeZone.input.value; - options.guestTimezone = guestTimezone.input.value; - options.window = preferredWindow.input.value; - overlay.close(); - openQuickWorkflowForm(options); - }, - ); - } else { - feedback - .failure( - `Could not create card: ${err.message || "request failed"} Select Create card to retry.`, - ) - .focus(); - } - setControlPending(createBtn, { pending: false, label: buttonLabel() }); - } finally { - cardSubmitInFlight = false; - } - }; - form.addEventListener("submit", submitCard); - } - - function findLiveWorkflowTemplate(liveTemplates, requestedTemplate) { - if (!requestedTemplate || !Array.isArray(liveTemplates)) return null; - const wantedId = normalizeTemplateMatchValue( - requestedTemplate.id || - requestedTemplate.templateId || - requestedTemplate.sourceTemplateId || - requestedTemplate.canonicalTemplateId, - ); - if (wantedId) { - const idMatches = liveTemplates.filter( - (template) => normalizeTemplateMatchValue(template.id) === wantedId, - ); - return idMatches.length === 1 ? idMatches[0] : null; - } - - const wantedSlug = normalizeTemplateMatchValue( - requestedTemplate.slug || requestedTemplate.type, - ); - if (!wantedSlug) return null; - const slugMatches = liveTemplates.filter((template) => { - const templateSlug = normalizeTemplateMatchValue( - template.slug || template.type, - ); - return templateSlug === wantedSlug; - }); - return slugMatches.length === 1 ? slugMatches[0] : null; - } - - function normalizeTemplateMatchValue(value) { - return String(value || "") - .trim() - .toLowerCase() - .replace(/\s+task template$/i, "") - .replace(/&/g, "and") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - } - function openRecurringForm(options = {}) { const config = options.config || null; const editing = Boolean(config?.id); diff --git a/frontend/src/surfaces/work-detail/card-summary.js b/frontend/src/surfaces/work-detail/card-summary.js index de4f170b1..ee21ed8a8 100644 --- a/frontend/src/surfaces/work-detail/card-summary.js +++ b/frontend/src/surfaces/work-detail/card-summary.js @@ -646,6 +646,8 @@ export function renderCardGuestInvitation({ request, workApiUrl, reload, watch = const sent = intake.status === "sent" && Boolean(intake.messageId); heading.textContent = sent ? "Invitation sent" : intake.status === "skipped" ? "Invitation not sent" + : intake.status === "paused" ? "Invitation paused" + : intake.status === "cancelled" ? "Invitation cancelled" : intake.status === "contact-pending" ? "Guest contact details needed" : intake.status === "failed" ? "Invitation could not be sent" : "Invitation preparing"; section.append(heading); @@ -657,14 +659,18 @@ export function renderCardGuestInvitation({ request, workApiUrl, reload, watch = if (intake.startIso) { const time = document.createElement("p"); const parsed = new Date(intake.startIso); - time.textContent = `Proposed time: ${Number.isNaN(parsed.getTime()) ? intake.startIso : new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", timeZone: "Europe/Berlin" }).format(parsed)} (Berlin)`; + time.textContent = `Proposed time: ${Number.isNaN(parsed.getTime()) ? intake.startIso : new Intl.DateTimeFormat("en-GB", { + dateStyle: "full", timeStyle: "short", timeZone: "Europe/Berlin", + }).format(parsed)} (Berlin)`; section.append(time); } if (sent && intake.sentAt) { const delivered = document.createElement("p"); const timestamp = new Date(intake.sentAt); delivered.dataset.invitationSentAt = ""; - delivered.textContent = `Sent at: ${Number.isNaN(timestamp.getTime()) ? intake.sentAt : new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin" }).format(timestamp)} (Berlin)`; + delivered.textContent = `Sent at: ${Number.isNaN(timestamp.getTime()) ? intake.sentAt : new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin", + }).format(timestamp)} (Berlin)`; section.append(delivered); } if (sent) { @@ -711,7 +717,9 @@ export function renderCardGuestInvitation({ request, workApiUrl, reload, watch = }); section.append(button, feedback); } - if (watch && ["queued", "preparing"].includes(intake.status)) { + const needsRefresh = value => ["queued", "preparing"].includes(value?.guestIntake?.status) + || (value?.guestIntake?.status === "paused" && value.status === "active"); + if (watch && needsRefresh(card)) { let timer; let attempts = 0; let stopped = false; @@ -726,7 +734,7 @@ export function renderCardGuestInvitation({ request, workApiUrl, reload, watch = if (next?.guestIntake) { const receipt = renderCardGuestInvitation({ request, workApiUrl, reload, watch: false }, next); section.replaceChildren(...receipt.childNodes); - if (!["queued", "preparing"].includes(next.guestIntake.status)) { stop(); return; } + if (!needsRefresh(next)) { stop(); return; } } } catch { /* Retain the last actual receipt and try within the bounded window. */ } if (!stopped) { timer = schedule(poll, 3000); timer?.unref?.(); } diff --git a/frontend/test/guest-invitation.test.mjs b/frontend/test/guest-invitation.test.mjs index 809034218..4de34850e 100644 --- a/frontend/test/guest-invitation.test.mjs +++ b/frontend/test/guest-invitation.test.mjs @@ -66,3 +66,17 @@ test('closing the Card stops pending invitation checks without another request', await scheduled[0](); assert.equal(requests, 0); }); + +test('paused and cancelled receipts show honest states without background polling on parked Cards', () => { + for (const status of ['paused', 'cancelled']) { + const scheduled = []; + const section = render({ status }, { schedule: callback => { scheduled.push(callback); return 1; } }); + assert.match(section.textContent, new RegExp(`Invitation ${status}`)); + assert.doesNotMatch(section.textContent, /Invitation preparing/); + assert.equal(scheduled.length, 0); + } + globalThis.document = new FakeDocument(); + const scheduled = []; + renderCardGuestInvitation({ request: async () => ({}), workApiUrl: x => x, schedule: callback => { scheduled.push(callback); return 1; } }, { id: 'active-card', status: 'active', guestIntake: { status: 'paused' } }); + assert.equal(scheduled.length, 1, 'resumed active Card refreshes its paused receipt'); +}); From 388c1c940fc97be96d1d2e3274edc46fc104819a Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:27:35 +0100 Subject: [PATCH 03/10] Keep intake retries lifecycle-aware and schedules identity-stable --- backend/src/events/guestIntakeStore.ts | 10 ++--- backend/src/events/guestIntakeWorker.ts | 23 ++++++++-- backend/tests/guest-intake-worker.test.ts | 34 +++++++++++++-- infra/template.full.yaml | 51 +++++++++++++++++------ tests/infra/test_template.py | 10 +++++ 5 files changed, 102 insertions(+), 26 deletions(-) diff --git a/backend/src/events/guestIntakeStore.ts b/backend/src/events/guestIntakeStore.ts index 76ce273ab..29ecf28e8 100644 --- a/backend/src/events/guestIntakeStore.ts +++ b/backend/src/events/guestIntakeStore.ts @@ -1,14 +1,14 @@ import { GetCommand, PutCommand, ScanCommand, UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { TABLE_ARTIFACTS } from '../db/tableNames'; import type { GuestIntakePolicy } from './guestIntakePolicy'; -export type GuestIntakeStatus = 'queued' | 'preparing' | 'sent' | 'skipped' | 'failed' | 'contact-pending'; +export type GuestIntakeStatus = 'queued' | 'preparing' | 'sent' | 'skipped' | 'failed' | 'contact-pending' | 'paused' | 'cancelled'; export interface GuestIntakeJob { id: string; cardId: string; taskId: string; actorId: string; type: string; creationKey: string; fingerprint: string; status: GuestIntakeStatus; guestEmail?: string; guestName?: string; topic?: string; skipEmail: boolean; requestedDate?: string; requestedTime?: string; timeZone?: string; guestTimezone?: string; requestedWindow?: string; policy?: GuestIntakePolicy; holdId?: string; docId?: string; selectedDate?: string; selectedTime?: string; selectedWindow?: string; - messageId?: string; sentAt?: string; startIso?: string; error?: string; retryable?: boolean; leaseUntil?: number; + messageId?: string; sentAt?: string; startIso?: string; error?: string; lastReportedError?: string; retryable?: boolean; leaseUntil?: number; createdAt: string; updatedAt: string; } export const guestIntakeKey = (id: string) => ({ PK: `GUESTINTAKE#${id}`, SK: `GUESTINTAKE#${id}` }); @@ -26,8 +26,8 @@ export async function claimGuestIntakeJob(db: DynamoDBDocumentClient, job: Guest const now = Date.now(); try { await db.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: guestIntakeKey(job.id), - UpdateExpression: 'SET leaseUntil = :lease', ConditionExpression: 'attribute_exists(PK) AND (attribute_not_exists(leaseUntil) OR leaseUntil < :now) AND #s <> :sent AND #s <> :skipped', ExpressionAttributeNames: { '#s': 'status' }, - ExpressionAttributeValues: { ':lease': now + 150000, ':now': now, ':sent': 'sent', ':skipped': 'skipped' } })); + UpdateExpression: 'SET leaseUntil = :lease', ConditionExpression: 'attribute_exists(PK) AND (attribute_not_exists(leaseUntil) OR leaseUntil < :now) AND (#s = :queued OR #s = :preparing OR #s = :failed OR #s = :paused)', ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':lease': now + 150000, ':now': now, ':queued': 'queued', ':preparing': 'preparing', ':failed': 'failed', ':paused': 'paused' } })); job.leaseUntil = now + 150000; return true; } catch (e) { if ((e as Error).name === 'ConditionalCheckFailedException') return false; throw e; } @@ -40,7 +40,7 @@ export async function listResumableGuestIntakes(db: DynamoDBDocumentClient): Pro const page = await db.send(new ScanCommand({ TableName: TABLE_ARTIFACTS, ConsistentRead: true, FilterExpression: 'begins_with(PK, :prefix)', ExpressionAttributeValues: { ':prefix': 'GUESTINTAKE#' }, ...(cursor ? { ExclusiveStartKey: cursor } : {}) })); - for (const item of page.Items || []) if (['queued', 'preparing', 'failed'].includes(item.status) && item.retryable !== false && (!item.leaseUntil || item.leaseUntil < Date.now())) { + for (const item of page.Items || []) if (['queued', 'preparing', 'failed', 'paused'].includes(item.status) && item.retryable !== false && (!item.leaseUntil || item.leaseUntil < Date.now())) { const { PK, SK, ...job } = item; jobs.push(job as GuestIntakeJob); } cursor = page.LastEvaluatedKey; diff --git a/backend/src/events/guestIntakeWorker.ts b/backend/src/events/guestIntakeWorker.ts index 4b7bf7010..bd8bae429 100644 --- a/backend/src/events/guestIntakeWorker.ts +++ b/backend/src/events/guestIntakeWorker.ts @@ -26,24 +26,36 @@ async function projectJob(db: DynamoDBDocumentClient, job: GuestIntakeJob) { ...(job.guestEmail ? { recipient: job.guestEmail } : {}), ...(job.messageId ? { messageId: job.messageId } : {}), ...(job.sentAt ? { sentAt: job.sentAt } : {}), ...(job.startIso ? { startIso: job.startIso } : {}), ...(job.error ? { error: job.error } : {}) } })); } async function recordFailure(db: DynamoDBDocumentClient, job: GuestIntakeJob, error: unknown) { + const previousReportedError = job.lastReportedError; job.status = 'failed'; job.error = (error as Error).message || 'Invitation preparation failed'; const task = await getTaskConsistent(db, job.taskId); // Any durable dispatch claim without proven pre-provider failure needs reconciliation, never a blind resend. const prior = task?.podcastFirstContact; job.retryable = !(prior && prior.state !== 'sent' && !(prior.state === 'deferred' && provenPreSendFailure(prior.delivery))); + job.lastReportedError = job.error; await saveGuestIntakeJob(db, job); await projectJob(db, job); - if (task) await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { comment: `${job.error}. ${job.retryable ? 'DataOps will resume this same invitation automatically after the issue is fixed.' : 'Delivery outcome requires reconciliation before any retry.'}` }, actorId: job.actorId, triggerKind: 'guest-intake-failed' }); + if (task && previousReportedError !== job.error) await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { comment: `${job.error}. ${job.retryable ? 'DataOps will resume this same invitation automatically after the issue is fixed.' : 'Delivery outcome requires reconciliation before any retry.'}` }, actorId: job.actorId, triggerKind: 'guest-intake-failed' }); } export async function processGuestIntake(db: DynamoDBDocumentClient, id: string): Promise { let job = await getGuestIntakeJob(db, id); - if (!job || !APPROVED_GUEST_EVENT_TYPES.has(job.type) || ['sent', 'skipped', 'contact-pending'].includes(job.status) || job.retryable === false) return; + if (!job || !APPROVED_GUEST_EVENT_TYPES.has(job.type) || ['sent', 'skipped', 'contact-pending', 'cancelled'].includes(job.status) || job.retryable === false) return; if (!await claimGuestIntakeJob(db, job)) throw new Error('Invitation is already being prepared; retry after its lease'); job = (await getGuestIntakeJob(db, id))!; let allocator = false; try { const card = await getCardConsistent(db, job.cardId); - if (!card || card.status !== 'active') throw new Error('An active guest Card is required'); + if (!card || card.status === 'archived') { + job.status = 'cancelled'; job.retryable = false; job.error = 'Invitation cancelled because its Card was archived or removed'; + await saveGuestIntakeJob(db, job); if (card) await projectJob(db, job); return; + } + if (card.status === 'parked') { + if (job.status !== 'paused') { + job.status = 'paused'; job.error = 'Invitation paused while its Card is parked'; + await saveGuestIntakeJob(db, job); await projectJob(db, job); + } + return; + } if (!job.guestEmail || !job.policy?.enabled) throw new Error('Guest email and enabled event intake policy are required'); job.status = 'preparing'; delete job.error; await saveGuestIntakeJob(db, job); await projectJob(db, job); @@ -76,7 +88,10 @@ export async function processGuestIntake(db: DynamoDBDocumentClient, id: string) if (result.sync.state !== 'synced') throw new Error(`Calendar invitation is pending: ${result.sync.reason || 'provider unavailable'}`); event = result.event; } else { - if (event.status !== 'proposed' && event.status !== 'approved') throw new Error('The linked guest event was released; reconcile it before sending'); + if (event.status !== 'proposed' && event.status !== 'approved') { + job.status = 'cancelled'; job.retryable = false; job.error = 'Invitation cancelled because its linked event was released'; + await saveGuestIntakeJob(db, job); await projectJob(db, job); return; + } const op = event.operations.at(-1); if (op && op.state !== 'complete') { const result = await retryEventOperation(db, card.id, { operationKey: op.key, actor: job.actorId }); diff --git a/backend/tests/guest-intake-worker.test.ts b/backend/tests/guest-intake-worker.test.ts index d47c0417a..7b78dd7e3 100644 --- a/backend/tests/guest-intake-worker.test.ts +++ b/backend/tests/guest-intake-worker.test.ts @@ -4,12 +4,12 @@ import { createTaskChecklistFixture } from './helpers/task-checklist-fixture'; import { guestPortalSlots, recordGuestPortalTimeChoice, processGuestChoice } from '../src/events/guestIntakePortal'; import { before, after, test } from 'node:test'; import assert from 'node:assert/strict'; -import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { PutCommand, UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { getClient } from '../src/db/client'; import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; import { createTemplate } from '../src/db/templates'; -import { getCardConsistent } from '../src/db/cards'; -import { getTaskConsistent, listTasksByCardConsistent } from '../src/db/tasks'; +import { getCardConsistent, parkCard, resumeCard } from '../src/db/cards'; +import { getTaskConsistent, updateTask, listTasksByCardConsistent } from '../src/db/tasks'; import { getGuestIntakeJob } from '../src/events/guestIntakeStore'; import { processGuestIntake, pollGuestIntakeAcceptances } from '../src/events/guestIntakeWorker'; import { getEventRecord } from '../src/events/store'; @@ -18,7 +18,7 @@ import { setCalendarHoldGoogleDepsForTests } from '../src/db/calendarHolds'; import { setOwnerCalendarProviderClientsForTests } from '../src/calendar/ownerCalendarOverlay'; import { saveCalendarOverlaySettings } from '../src/db/calendar'; import { setSponsorDocEmailDepsForTests } from '../src/sponsorDocs/emailDelivery'; -import { TABLE_USERS, TABLE_ARTIFACTS } from '../src/db/tableNames'; +import { TABLE_USERS, TABLE_ARTIFACTS, TABLE_CARDS } from '../src/db/tableNames'; import { policy } from './helpers/eventPolicy'; import { createMockGoogleCalendar } from './helpers/guest-intake-google'; import { HOLD_ORGANIZER } from '../src/calendar/holdGoogleSync'; @@ -230,5 +230,31 @@ test('explicit recording time is not moved when its five-minute join lead overla const failed = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; assert.equal(failed.status, 'failed'); assert.equal(failed.requestedTime, '17:00'); assert.equal(failed.selectedTime, undefined); assert.equal(await getEventRecord(db, `hold_${created.card.id}`), null); assert.equal(mails, beforeMails); + const taskVersion = (await getTaskConsistent(db, failed.taskId))!.version; + await assert.rejects(processGuestIntake(db, failed.id), /requested date\/time is unavailable/); + assert.equal((await getTaskConsistent(db, failed.taskId))!.version, taskVersion, 'identical automatic failure must not append another Task update'); ownerEvents = []; }); + +test('parked Cards pause quietly and resume the same job; archived Cards cancel permanently', async () => { + const created = await card('webinar', { skipEmail: true }); + await parkCard(db, created.card.id, created.card.version); + await processGuestIntake(db, created.card.guestIntake.jobId); + const paused = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; + assert.equal(paused.status, 'paused'); assert.equal(paused.docId, undefined); + const taskVersion = (await getTaskConsistent(db, paused.taskId))!.version; + await processGuestIntake(db, paused.id); + assert.equal((await getTaskConsistent(db, paused.taskId))!.version, taskVersion); + const parked = (await getCardConsistent(db, created.card.id))!; + await resumeCard(db, parked.id, parked.version); await processGuestIntake(db, paused.id); + assert.equal((await getGuestIntakeJob(db, paused.id))!.status, 'skipped'); + const archived = await card('webinar'); const beforeMails = mails; + const owningTask = (await getTaskConsistent(db, archived.card.guestIntake.taskId))!; + await updateTask(db, owningTask.id, { currentTask: owningTask, expectedVersion: owningTask.version, patch: { status: 'skipped', skipReason: 'Synthetic owner cancelled intake' } }); + await processGuestIntake(db, archived.card.guestIntake.jobId); + assert.equal((await getGuestIntakeJob(db, archived.card.guestIntake.jobId))!.status, 'cancelled'); + const cancelledTask = (await getTaskConsistent(db, owningTask.id))!; + await updateTask(db, cancelledTask.id, { currentTask: cancelledTask, expectedVersion: cancelledTask.version, patch: { status: 'todo', skipReason: null } }); + await processGuestIntake(db, archived.card.guestIntake.jobId); + assert.equal(mails, beforeMails); assert.equal((await getGuestIntakeJob(db, archived.card.guestIntake.jobId))!.status, 'cancelled'); +}); diff --git a/infra/template.full.yaml b/infra/template.full.yaml index 81040f377..0514bdfb9 100644 --- a/infra/template.full.yaml +++ b/infra/template.full.yaml @@ -1388,19 +1388,6 @@ Resources: FilterCriteria: Filters: - Pattern: '{"eventName":["INSERT"],"dynamodb":{"NewImage":{"guestIntake":{"M":{"jobId":{"S":[{"exists":true}]}}}}}}' - GuestEventIntakeResume: - Type: Schedule - Properties: - Schedule: rate(2 minutes) - Enabled: true - Input: '{"source":"aws.events","detail":{"dataopsAction":"guest-event-intake-resume"}}' - GuestEventAcceptancePoll: - Type: Schedule - Properties: - Schedule: rate(2 minutes) - Enabled: true - Input: '{"source":"aws.events","detail":{"dataopsAction":"guest-event-acceptance-poll"}}' - CardBannerJobs: Type: SQS Properties: @@ -1451,6 +1438,44 @@ Resources: LogGroupName: !Sub /aws/lambda/${McpFunction} RetentionInDays: 30 + BackendFunctionGuestEventIntakeResume: + Type: AWS::Events::Rule + Properties: + Description: Resume safe pending guest invitations every two minutes. + ScheduleExpression: rate(2 minutes) + State: ENABLED + Targets: + - Arn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${BackendTargetFunctionName} + Id: BackendFunctionGuestEventIntakeResumeLambdaTarget + Input: '{"source":"aws.events","detail-type":"Scheduled Event","detail":{"dataopsAction":"guest-event-intake-resume"}}' + + BackendFunctionGuestEventIntakeResumePermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref BackendTargetFunctionName + Principal: events.amazonaws.com + SourceArn: !GetAtt BackendFunctionGuestEventIntakeResume.Arn + + BackendFunctionGuestEventAcceptancePoll: + Type: AWS::Events::Rule + Properties: + Description: Apply guest time choices and poll new proposed guest invitations every two minutes. + ScheduleExpression: rate(2 minutes) + State: ENABLED + Targets: + - Arn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${BackendTargetFunctionName} + Id: BackendFunctionGuestEventAcceptancePollLambdaTarget + Input: '{"source":"aws.events","detail-type":"Scheduled Event","detail":{"dataopsAction":"guest-event-acceptance-poll"}}' + + BackendFunctionGuestEventAcceptancePollPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref BackendTargetFunctionName + Principal: events.amazonaws.com + SourceArn: !GetAtt BackendFunctionGuestEventAcceptancePoll.Arn + BackendFunctionDailyBackendCron: Type: AWS::Events::Rule Properties: diff --git a/tests/infra/test_template.py b/tests/infra/test_template.py index 6108bc9be..078f16164 100644 --- a/tests/infra/test_template.py +++ b/tests/infra/test_template.py @@ -296,6 +296,16 @@ def test_backend_schedules_are_explicit_identity_stable_resources_with_exact_beh "input": '{"source":"aws.events","detail-type":"Scheduled Event","detail":{"dataopsAction":"dynamo-backup"}}', }, } + for suffix, action, description in [ + ("GuestEventIntakeResume", "guest-event-intake-resume", "Resume safe pending guest invitations every two minutes."), + ("GuestEventAcceptancePoll", "guest-event-acceptance-poll", "Apply guest time choices and poll new proposed guest invitations every two minutes."), + ]: + name = f"BackendFunction{suffix}" + schedules[name] = { + "target": "BackendTargetFunctionName", "description": description, + "expression": "rate(2 minutes)", "state": "ENABLED", "target_id": f"{name}LambdaTarget", + "input": '{"source":"aws.events","detail-type":"Scheduled Event","detail":{"dataopsAction":"' + action + '"}}', + } for rule_id, expected in schedules.items(): target = { "Arn": {"Sub": f"arn:${{AWS::Partition}}:lambda:${{AWS::Region}}:${{AWS::AccountId}}:function:${{{expected['target']}}}"}, From c91d265628595b9773615c5f303107e83688a7ca Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:37:11 +0100 Subject: [PATCH 04/10] Cover authored content and event metadata across guest form switches --- backend/tests/sponsor-docs.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/tests/sponsor-docs.test.ts b/backend/tests/sponsor-docs.test.ts index fdedd614b..34cf48f2a 100644 --- a/backend/tests/sponsor-docs.test.ts +++ b/backend/tests/sponsor-docs.test.ts @@ -1159,6 +1159,8 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { it('switches a webinar form to the workshop type, keeping content and relinking the card', async () => { const card = await createCard(client, { ...templateLink(), title: '[Webinar] Synthetic switch', people: ['Test Speaker'] }); + const hold = buildHold({ title: 'Synthetic switch', topic: 'Synthetic topic', date: '2030-12-02', window: 'midday', cardId: card.id }, 'test'); + await putCalendarHold(client, hold); const { doc } = JSON.parse((await staffReq('POST', '/api/sponsor-docs', { cardId: card.id })).body); await staffReq('PUT', `/api/sponsor-docs/${doc.id}`, { content: { talkTitle: 'Kept', bio: 'Bio. https://x.test/me' } }); const switched = await staffReq('PUT', `/api/sponsor-docs/${doc.id}`, { template: 'workshop', content: { bio: 'Bio.', twitterUrl: 'https://x.test/me' } }); @@ -1167,6 +1169,12 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { assert.equal(next.template, 'workshop'); assert.equal(next.linkType, 'Workshop document'); assert.deepEqual([next.content.talkTitle, next.content.bio, next.content.twitterUrl], ['Kept', 'Bio.', 'https://x.test/me']); + assert.equal(next.eventMetadata.startIso, hold.startIso); + assert.equal(next.content.proposedSlotLabel, next.eventMetadata.label); + const stored = (await client.send(new GetCommand({ TableName: TABLE_ARTIFACTS, Key: sponsorDocKey(doc.id) }))).Item!; + assert.equal(Object.hasOwn(stored.content, 'proposedSlotLabel'), false, 'a type switch must not turn a live event projection into authored content'); + const derivedEdit = await staffReq('PUT', `/api/sponsor-docs/${doc.id}`, { content: { proposedSlotLabel: 'Invented time' } }); + assert.equal(derivedEdit.statusCode, 422, 'an explicit derived-field edit remains forbidden'); const reloaded = JSON.parse((await staffReq('GET', `/api/cards/${card.id}`)).body).card; assert.ok(reloaded.cardLinks.some((link: { name: string; url: string }) => link.name === 'Workshop document' && link.url === next.docUrl)); assert.ok(!reloaded.cardLinks.some((link: { url: string }) => link.url.includes('/webinar-docs/'))); From fef3c2740b07d1bca431fe401b9155968d2d55ef Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:38:38 +0100 Subject: [PATCH 05/10] Preserve authored content when switching guest form types --- backend/src/db/sponsorDocs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/sponsorDocs.ts b/backend/src/db/sponsorDocs.ts index c694d7ac3..b21297355 100644 --- a/backend/src/db/sponsorDocs.ts +++ b/backend/src/db/sponsorDocs.ts @@ -336,7 +336,7 @@ export async function updateSponsorDoc( const nextTemplate = input.actorKind === 'staff' && input.template ? input.template : doc.template; let nextContent: Record; if (nextTemplate !== doc.template) { - const carried: Record = { ...doc.content, ...(input.content || {}) }; + const carried: Record = { ...loaded.doc.content, ...(input.content || {}) }; nextContent = sanitizeTemplateContent(nextTemplate, carried, defaultContentForTemplate(nextTemplate)); const dropped = Object.entries(carried) .filter(([key, value]) => !Object.hasOwn(nextContent, key) && String(value ?? '').trim()); From 58f1dfdf3907c5f5e4f907265c75d4675ab35f0b Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:38:38 +0100 Subject: [PATCH 06/10] Support explicit Calendar-only blockers on unconfigured event Cards --- backend/src/events/manualCalendarPolicy.ts | 13 +++++ backend/src/events/service.ts | 57 +++++++++++++++++-- backend/src/routes/events.ts | 7 +++ backend/tests/calendar-invite-sop.test.ts | 4 +- backend/tests/card-calendar-invites.test.ts | 2 +- backend/tests/card-event-time.test.ts | 2 +- backend/tests/guest-intake-worker.test.ts | 37 +++++++++++- backend/tests/helpers/historical-card-hold.ts | 21 +++++++ cli/src/commands/event.mjs | 16 +++++- cli/test/event-explicit-blocker.test.mjs | 17 ++++++ 10 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 backend/src/events/manualCalendarPolicy.ts create mode 100644 backend/tests/helpers/historical-card-hold.ts create mode 100644 cli/test/event-explicit-blocker.test.mjs diff --git a/backend/src/events/manualCalendarPolicy.ts b/backend/src/events/manualCalendarPolicy.ts new file mode 100644 index 000000000..8f1bc9666 --- /dev/null +++ b/backend/src/events/manualCalendarPolicy.ts @@ -0,0 +1,13 @@ +import { HOLD_INVITE_LEAD_MINUTES, HOLD_REQUIRED_ATTENDEES, HOLD_ZOOM_LOCATION } from '../calendar/holds'; +import type { EventPolicy } from './policy'; +/** Explicit manual blocker policy. No event-type inference or first-contact email is involved. */ +export function manualCalendarPolicy(duration: unknown, timeZone = 'Europe/Berlin'): EventPolicy { + if (!Number.isInteger(duration) || Number(duration) < 1 || Number(duration) > 1440) throw new Error('An unconfigured Card needs an explicit --duration in minutes'); + try { new Intl.DateTimeFormat('en', { timeZone }); } catch { throw new Error('timeZone must be an IANA time zone'); } + return { timeZone, durationMinutes: Number(duration), windows: {}, expiresDays: 7, + titles: { proposed: '[Proposed] {title}', approved: '{title}' }, + description: 'Event: {date} at {time} {timeZone}\nGuest: {guestNames}\nGuest form: {documentUrl}', inviteText: 'Zoom: {zoomUrl}', + guests: { proposal: true, approved: true, requiredEmails: [...HOLD_REQUIRED_ATTENDEES], leadMinutes: HOLD_INVITE_LEAD_MINUTES }, + zoom: { enabled: true, url: HOLD_ZOOM_LOCATION }, calendarAcceptance: { enabled: true, quorum: 'any' }, + notifications: { propose: { calendar: 'all', emails: [] }, approve: { calendar: 'all', emails: [] }, reschedule: { calendar: 'all', emails: [] }, release: { calendar: 'all', emails: [] } } }; +} diff --git a/backend/src/events/service.ts b/backend/src/events/service.ts index 73fc20ddc..23632db78 100644 --- a/backend/src/events/service.ts +++ b/backend/src/events/service.ts @@ -1,3 +1,8 @@ +import { cardEventInterval } from '../calendar/cardEventTime'; +import { manualCalendarPolicy } from './manualCalendarPolicy'; +import { claimGuestSlotAllocator, releaseAllocator } from './guestIntakeAllocator'; +import { guestEventSlots } from './guestIntakeSlots'; +import type { GuestIntakeJob } from './guestIntakeStore'; import { resolveGuestAcceptanceTask } from './guestIntakeCompletion'; import { randomUUID } from 'node:crypto'; import { @@ -639,6 +644,10 @@ export async function reconcileEventOperation( export interface EventTransitionInput { date?: string; time?: string; + timeZone?: string; + durationMinutes?: number; + /** Internal explicit manual policy, persisted on the Event rather than its Template. */ + manualPolicy?: EventPolicy; window?: string; title?: string; topic?: string; @@ -693,7 +702,10 @@ async function runTransition( let command: EventCommand; if (transition === 'propose' || transition === 'reschedule') { - const { templateId, templateVersion, policy } = await resolveCardEventPolicy(client, card); + const { templateId, templateVersion, policy } = input.manualPolicy + ? { templateId: card.templateId, templateVersion: card.templateVersion || 1, policy: parseEventPolicy(input.manualPolicy) } + : await resolveCardEventPolicy(client, card); + if (input.durationMinutes !== undefined && input.durationMinutes !== policy.durationMinutes) throw new EventInputError('duration conflicts with the configured event policy'); // Newly proposed events use the current label; existing records keep their saved policy until a transition. policy.titles.proposed = '[Proposed] ' + policy.titles.proposed.replace(/^(?:\(Proposal\)|\[Proposed\])\s*/i, ''); if (input.suppressGuestNotifications) { @@ -738,7 +750,14 @@ async function runTransition( ? input.documentUrl : cardGuestDocument(card) || current?.renderValues?.documentUrl || ''; - const time = input.time; + let time = input.time; + let proposalDate = input.date || current?.date || card.anchorDate; + if (time && proposalDate && input.timeZone && input.timeZone !== policy.timeZone) { + let instant: ReturnType; + try { instant = cardEventInterval(`${proposalDate} ${time}`, policy.durationMinutes, input.timeZone); } catch (error) { throw new EventInputError((error as Error).message); } + const p = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: policy.timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(instant.startIso)).map(p => [p.type,p.value])); + proposalDate = `${p.year}-${p.month}-${p.day}`; time = `${p.hour}:${p.minute}`; + } const window = input.window !== undefined ? input.window @@ -752,7 +771,7 @@ async function runTransition( expectedVersion: current?.version ?? 0, actor, now, - date: input.date || current?.date || card.anchorDate, + date: proposalDate, ...(time !== undefined ? { time } : {}), ...(window !== undefined ? { window } : {}), proposal: { @@ -876,6 +895,32 @@ async function buildResult( }; } +async function explicitManualTransition(client: DynamoDBDocumentClient, transition: 'propose' | 'reschedule', targetId: string, input: EventTransitionInput) { + const { card, event: current } = await resolveEventTarget(client, targetId); + try { await resolveCardEventPolicy(client, card); return runTransition(client, transition, targetId, input); } + catch (error) { + if (!(error instanceof EventInputError && error.message.includes('does not declare an eventPolicy'))) throw error; + } + if (!current && (!input.date || !input.time || !input.guestEmails?.length)) throw new EventInputError('An unconfigured Card needs --date, --time, --duration and --guest-emails for an explicit Calendar-only blocker'); + let policy: EventPolicy; + try { policy = current?.policy || manualCalendarPolicy(input.durationMinutes, input.timeZone || 'Europe/Berlin'); } + catch (error) { throw new EventInputError((error as Error).message); } + if (input.durationMinutes !== undefined && input.durationMinutes !== policy.durationMinutes) throw new EventInputError('duration conflicts with the saved event policy'); + const date = input.date || current?.date; + const time = input.time || (input.window ? policy.windows[input.window] : current?.time); + if (!date || !time) throw new EventInputError('Supply --date and --time for this Calendar-only blocker'); + const owner = `manual-${randomUUID()}`; + if (!await claimGuestSlotAllocator(client, owner)) throw new EventConflictError('Another event is reserving a time; retry shortly'); + try { + const check = { cardId: card.id, type: 'manual-calendar-blocker', requestedDate: date, requestedTime: time, timeZone: input.timeZone || policy.timeZone } as GuestIntakeJob; + const slots = await guestEventSlots(client, check, policy, 1).catch(error => { throw new EventInputError((error as Error).message); }); + if (!slots.length) throw new EventInputError('The requested Calendar interval is unavailable'); + // Normalize the explicit instant to the persisted policy zone without moving it. + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: policy.timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(slots[0].startIso)).map(p => [p.type,p.value])); + return await runTransition(client, transition, targetId, { ...input, date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}`, window: undefined, timeZone: policy.timeZone, manualPolicy: policy }); + } finally { await releaseAllocator(client, owner); } +} + export async function proposeEvent( client: DynamoDBDocumentClient, targetId: string, @@ -888,7 +933,7 @@ export async function proposeEvent( `Card '${card.id}' already has an active event (${current.id}, status=${current.status}). Use reschedule to change its date/time.`, ); } - return runTransition(client, 'propose', targetId, input); + return explicitManualTransition(client, 'propose', targetId, input); } export async function approveEvent( @@ -908,9 +953,9 @@ export async function rescheduleEvent( ): Promise { const { event: current } = await resolveEventTarget(client, targetId); if (!current || current.status === 'released') { - return runTransition(client, 'propose', targetId, input); + return explicitManualTransition(client, 'propose', targetId, input); } - return runTransition(client, 'reschedule', targetId, input); + return explicitManualTransition(client, 'reschedule', targetId, input); } export async function releaseEvent( diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 77f6b1d87..647236149 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -42,6 +42,11 @@ async function formatEventResponse( } function parseTransitionInput(body: Record, actorId: string) { + if (body.durationMinutes !== undefined && (!Number.isInteger(body.durationMinutes) || body.durationMinutes < 1 || body.durationMinutes > 1440)) throw new EventInputError('durationMinutes must be an explicit integer from 1 to 1440'); + if (body.timeZone !== undefined) { + if (typeof body.timeZone !== 'string') throw new EventInputError('timeZone must be an IANA time zone'); + try { new Intl.DateTimeFormat('en', { timeZone: body.timeZone }); } catch { throw new EventInputError('timeZone must be an IANA time zone'); } + } const guestEmails = Array.isArray(body.guestEmails) ? body.guestEmails.map((e: unknown) => String(e).trim()).filter(Boolean) : typeof body.guestEmail === 'string' && body.guestEmail.trim() @@ -50,6 +55,8 @@ function parseTransitionInput(body: Record, actorId: string) { ? [body.guestRef.trim()] : undefined; return { + ...(body.durationMinutes !== undefined ? { durationMinutes: Number(body.durationMinutes) } : {}), + ...(typeof body.timeZone === 'string' ? { timeZone: body.timeZone } : {}), ...(typeof body.date === 'string' && body.date.trim() ? { date: body.date.trim() } : {}), ...(typeof body.time === 'string' && body.time.trim() ? { time: body.time.trim() } : {}), ...(typeof body.window === 'string' && body.window.trim() ? { window: body.window.trim() } : {}), diff --git a/backend/tests/calendar-invite-sop.test.ts b/backend/tests/calendar-invite-sop.test.ts index 9ac9dbfac..40c6afa15 100644 --- a/backend/tests/calendar-invite-sop.test.ts +++ b/backend/tests/calendar-invite-sop.test.ts @@ -2,7 +2,7 @@ import { after, before, afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { TABLE_CARDS, TABLE_TEMPLATES } from '../src/db/tableNames'; -import { handler } from '../src/handler'; +import { invokeHistoricalHoldFixture as handler } from './helpers/historical-card-hold'; import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; import { getClient } from '../src/db/client'; import { getCalendarHold, putCalendarHold } from '../src/db/calendarHolds'; @@ -54,7 +54,7 @@ describe('calendar invite SOP contract', () => { assert.equal(created.status, 201); const hold = created.hold; assert.equal(hold.title, input.title); - assert.equal(hold.googleTitle, `(Proposal) ${input.title}`); + assert.equal(hold.googleTitle, `[Proposed] ${input.title}`); assert.equal(hold.googleSync.state, 'synced', hold.googleSync.reason); assert.deepEqual(Object.keys(hold.googleEventIds).sort(), [HOLD_ORGANIZER, ...DEFAULT_HOLD_GOOGLE_CALENDARS].sort()); const proposals = [...google.events.values()].flatMap((store) => [...store.values()]); diff --git a/backend/tests/card-calendar-invites.test.ts b/backend/tests/card-calendar-invites.test.ts index e902674ae..2e7878894 100644 --- a/backend/tests/card-calendar-invites.test.ts +++ b/backend/tests/card-calendar-invites.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { createHash } from 'node:crypto'; import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; -import { handler } from '../src/handler'; +import { invokeHistoricalHoldFixture as handler } from './helpers/historical-card-hold'; import { getClient } from '../src/db/client'; import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; import { createTask } from '../src/db/tasks'; diff --git a/backend/tests/card-event-time.test.ts b/backend/tests/card-event-time.test.ts index 096f579ee..88569616a 100644 --- a/backend/tests/card-event-time.test.ts +++ b/backend/tests/card-event-time.test.ts @@ -27,7 +27,7 @@ test('reject invalid dates, DST gaps/folds, invalid zone and duration', () => { }); import { before, after, describe } from 'node:test'; -import { handler } from '../src/handler'; +import { invokeHistoricalHoldFixture as handler } from './helpers/historical-card-hold'; import { getClient } from '../src/db/client'; import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; import { createTemplate } from '../src/db/templates'; diff --git a/backend/tests/guest-intake-worker.test.ts b/backend/tests/guest-intake-worker.test.ts index 7b78dd7e3..301bd49aa 100644 --- a/backend/tests/guest-intake-worker.test.ts +++ b/backend/tests/guest-intake-worker.test.ts @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import { PutCommand, UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { getClient } from '../src/db/client'; import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; -import { createTemplate } from '../src/db/templates'; +import { createTemplate, getTemplate } from '../src/db/templates'; import { getCardConsistent, parkCard, resumeCard } from '../src/db/cards'; import { getTaskConsistent, updateTask, listTasksByCardConsistent } from '../src/db/tasks'; import { getGuestIntakeJob } from '../src/events/guestIntakeStore'; @@ -258,3 +258,38 @@ test('parked Cards pause quietly and resume the same job; archived Cards cancel await processGuestIntake(db, archived.card.guestIntake.jobId); assert.equal(mails, beforeMails); assert.equal((await getGuestIntakeJob(db, archived.card.guestIntake.jobId))!.status, 'cancelled'); }); + +test('an unconfigured other-type Card supports explicit Calendar-only blockers without enabling intake', async () => { + const template = await createTemplate(db, { name: 'Synthetic office hours', type: 'office-hours', taskDefinitions: [] }); + const created = await invoke('POST', '/api/cards', { templateId: template.id, title: 'Synthetic Calendar-only event', anchorDate: '2030-12-19' }); + assert.equal(created.statusCode, 201, created.body); + const target = JSON.parse(created.body).card; + assert.equal(target.guestIntake, undefined); + const beforeMails = mails; + const missing = await invoke('POST', `/api/events/${target.id}/propose`, { date: '2030-12-19', time: '17:00', guestEmails: ['synthetic-guest@example.test'] }); + assert.equal(missing.statusCode, 422, missing.body); assert.match(JSON.parse(missing.body).error, /duration/); + const proposed = await invoke('POST', `/api/events/${target.id}/propose`, { date: '2030-12-19', time: '12:00', timeZone: 'America/New_York', durationMinutes: 45, guestEmails: ['synthetic-guest@example.test'] }); + assert.equal(proposed.statusCode, 201); + const event = JSON.parse(proposed.body).event; + assert.equal(event.durationMinutes, 45); assert.equal(event.startIso, '2030-12-19T17:00:00.000Z'); + assert.equal(event.policy.guests.leadMinutes, 5); assert.equal(event.policy.zoom.enabled, true); + assert.equal(event.operations[0].desired.emails.length, 0); assert.equal(mails, beforeMails); + assert.equal((await getTemplate(db, template.id))!.eventPolicy, undefined); + assert.equal((await getCardConsistent(db, target.id))!.guestIntake, undefined); + const moved = await invoke('POST', `/api/events/${target.id}/reschedule`, { date: '2030-12-20', time: '12:00' }); + assert.equal(moved.statusCode, 200); + const newEvent = JSON.parse(moved.body).event; + assert.equal(newEvent.durationMinutes, 45); assert.deepEqual(newEvent.googleEventIds, event.googleEventIds); + google.setAttendeeResponse(newEvent.googleEventIds[HOLD_ORGANIZER], 'synthetic-guest@example.test', 'accepted'); + await pollGuestIntakeAcceptances(db); + assert.equal((await getEventRecord(db, event.id))!.status, 'approved'); assert.equal(mails, beforeMails); + const configured = await card('webinar', { skipEmail: true }); + const conflict = await invoke('POST', `/api/events/${configured.card.id}/propose`, { date: '2030-12-20', time: '12:30', durationMinutes: 45, guestEmails: ['synthetic-guest@example.test'] }); + assert.equal(conflict.statusCode, 422, conflict.body); assert.match(JSON.parse(conflict.body).error, /duration conflicts/); +}); + +test('new Card-linked blockers cannot be created through the private legacy hold endpoint', async () => { + const created = await card('webinar', { skipEmail: true }); + const result = await invoke('POST', '/api/calendar-holds', { cardId: created.card.id, date: '2030-12-23', title: 'Synthetic blocker', topic: 'Synthetic topic', guestRef: 'synthetic-guest@example.test' }); + assert.equal(result.statusCode, 422); assert.equal(JSON.parse(result.body).code, 'use_card_event_proposal'); +}); diff --git a/backend/tests/helpers/historical-card-hold.ts b/backend/tests/helpers/historical-card-hold.ts new file mode 100644 index 000000000..a7f2da34e --- /dev/null +++ b/backend/tests/helpers/historical-card-hold.ts @@ -0,0 +1,21 @@ +import { handler as realHandler } from '../../src/handler'; +import { getClient } from '../../src/db/client'; +import { getCalendarHold, putCalendarHold } from '../../src/db/calendarHolds'; +import type { LambdaEvent, LambdaResponse } from '../../src/types'; + +/** Seed a historical Card-linked hold while exercising its original provider proposal. + * New Card-linked proposals use /api/events; these tests cover previously saved + * holds that must still confirm/reconcile safely after the creation route changes. */ +export async function invokeHistoricalHoldFixture(event: LambdaEvent, context?: unknown): Promise { + const body = event.body ? JSON.parse(event.body) : null; + if (event.httpMethod !== 'POST' || event.path !== '/api/calendar-holds' || !body?.cardId) return realHandler(event, context); + const { cardId, ...standalone } = body; + const response = await realHandler({ ...event, body: JSON.stringify(standalone) }, context); + if (response.statusCode !== 201) return response; + const payload = JSON.parse(response.body); + const db = await getClient(); + const hold = await getCalendarHold(db, payload.hold.id); + if (!hold) throw new Error('Historical fixture hold was not persisted'); + await putCalendarHold(db, { ...hold, cardId }); + return { ...response, body: JSON.stringify({ ...payload, hold: { ...payload.hold, cardId } }) }; +} diff --git a/cli/src/commands/event.mjs b/cli/src/commands/event.mjs index a2bd447f3..352544031 100644 --- a/cli/src/commands/event.mjs +++ b/cli/src/commands/event.mjs @@ -12,16 +12,20 @@ the same lifecycle: Per-type behaviour (Google Calendar titles, duration, windows, Zoom location, guest invites, transition emails, and guest calendar-accept auto-approval) -comes strictly from the Card Template's eventPolicy. +comes from the Card Template's eventPolicy. For a Card without a policy, +provide --date, --time, --duration and --guest-emails explicitly. DataOps +checks availability and saves a Calendar-only blocker policy on that event +using the standard join lead, required attendees and Zoom. No first-contact +email or automatic intake is enabled. Later changes reuse that saved policy. Usage dataops event propose --date YYYY-MM-DD [--time HH:MM | --window NAME] [--title T] [--topic TOPIC] [--guest-names "Name, Name"] - [--guest-emails "a@b.c,d@e.f"] [--document-url URL] + [--guest-emails "a@b.c,d@e.f"] [--document-url URL] [--duration MINUTES] [--time-zone IANA] dataops event approve [--guest-emails "a@b.c"] [--document-url URL] dataops event reschedule --date YYYY-MM-DD [--time HH:MM | --window NAME] [--title T] [--topic TOPIC] [--guest-names "Name, Name"] - [--guest-emails "a@b.c,d@e.f"] [--document-url URL] + [--guest-emails "a@b.c,d@e.f"] [--document-url URL] [--duration MINUTES] [--time-zone IANA] dataops event release dataops event show dataops event retry [--force-email-retry] @@ -71,6 +75,10 @@ function buildTransitionPayload(args, { requireDate = false } = {}) { if (time !== undefined && !/^([01]\d|2[0-3]):([0-5]\d)$/.test(time)) { throw new UsageError("--time must be HH:MM (24h)."); } + const duration = args.duration === undefined ? undefined : Number(args.duration); + if (duration !== undefined && (typeof args.duration === 'boolean' || !Number.isInteger(duration) || duration < 1 || duration > 1440)) throw new UsageError('--duration must be a positive number of minutes (at most 1440).'); + const timeZone = optionalString(args, 'timeZone'); + if (timeZone) { try { new Intl.DateTimeFormat('en', { timeZone }); } catch { throw new UsageError('--time-zone must be an IANA time zone.'); } } const title = optionalString(args, "title"); const topic = optionalString(args, "topic"); const guestNames = optionalString(args, "guestNames") ?? optionalString(args, "guest-names"); @@ -79,6 +87,8 @@ function buildTransitionPayload(args, { requireDate = false } = {}) { const operationKey = optionalString(args, "operationKey") ?? optionalString(args, "operation-key"); return { + ...(duration !== undefined ? { durationMinutes: duration } : {}), + ...(timeZone ? { timeZone } : {}), ...(date !== undefined ? { date } : {}), ...(time !== undefined ? { time } : {}), ...(window !== undefined ? { window } : {}), diff --git a/cli/test/event-explicit-blocker.test.mjs b/cli/test/event-explicit-blocker.test.mjs new file mode 100644 index 000000000..3c0792354 --- /dev/null +++ b/cli/test/event-explicit-blocker.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { isolateConfig, captureIo, fakePortal } from './helpers.mjs'; +isolateConfig(); +const original = globalThis.fetch; +const { run } = await import('../src/cli.mjs'); +afterEach(() => { globalThis.fetch = original; }); +test('manual Calendar-only blocker forwards explicit duration and IANA clock zone', async () => { + const portal = fakePortal({ 'POST /api/events/card-1/propose': ({body}) => { assert.equal(body.durationMinutes,45); assert.equal(body.timeZone,'America/New_York'); return {event:{id:'hold_card-1',status:'proposed'},sync:{state:'synced'}}; } }); + const code = await run(['event','propose','card-1','--date','2030-12-19','--time','12:00','--duration','45','--time-zone','America/New_York','--guest-emails','guest@example.test'], captureIo()); + assert.equal(code,0); assert.equal(portal.length,1); +}); +test('invalid duration and time zone fail before API writes', async () => { + let calls = 0; globalThis.fetch = async () => { calls++; throw new Error('Unexpected request'); }; + for (const flags of [['--duration','0'],['--duration'],['--time-zone','Not/AZone']]) assert.equal(await run(['event','propose','card-1','--date','2030-12-19','--time','12:00',...flags],captureIo()),2); + assert.equal(calls,0); +}); From 3df144390e610be8e6157836441c5c25083ebc15 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:43:21 +0100 Subject: [PATCH 07/10] Keep missing-date regression scoped to a valid Card Template --- backend/tests/api-cards.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/api-cards.test.ts b/backend/tests/api-cards.test.ts index 101e81a22..8b4a4dab1 100644 --- a/backend/tests/api-cards.test.ts +++ b/backend/tests/api-cards.test.ts @@ -356,6 +356,7 @@ describe('API — Cards', () => { it('returns 400 when anchorDate is missing', async () => { const res = await invoke('POST', '/api/cards', { title: 'Test', + templateId: EMPTY_TEMPLATE_ID, }); assert.strictEqual(res.statusCode, 400); From 38cc5d3887ddccbb4df09a346e31fd3acfe629b8 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:50:12 +0100 Subject: [PATCH 08/10] Package every guest intake frontend module in the Lambda artifact --- backend/src/docs/frontend-assets.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/docs/frontend-assets.json b/backend/src/docs/frontend-assets.json index 8e082370a..0591e5815 100644 --- a/backend/src/docs/frontend-assets.json +++ b/backend/src/docs/frontend-assets.json @@ -159,7 +159,9 @@ "src/surfaces/operations/newsletter-preview.js", "src/surfaces/operations/sponsor-doc-bullets.js", "src/surfaces/operations/sponsor-doc-email-card.js", + "src/surfaces/operations/sponsor-doc-event-time.js", "src/surfaces/operations/sponsor-doc-gate.js", + "src/surfaces/operations/sponsor-doc-guest-schedule.js", "src/surfaces/operations/sponsor-doc-radio.js", "src/surfaces/operations/sponsor-doc-select.js", "src/surfaces/operations/sponsor-docs.js", @@ -195,6 +197,7 @@ "src/surfaces/tasks/queue-route-context.js", "src/surfaces/tasks/queue-skeleton.js", "src/surfaces/tasks/queue.js", + "src/surfaces/tasks/quick-card-create.js", "src/surfaces/tasks/quick-create.js", "src/surfaces/tasks/recurring.js", "src/surfaces/tasks/template-list.js", From 8e8d21bb4ada7150be9354d911542264085a84cf Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 22:51:34 +0100 Subject: [PATCH 09/10] Keep newly packaged intake helpers within source readability limits --- frontend/src/surfaces/operations/sponsor-doc-event-time.js | 5 +++-- frontend/src/surfaces/tasks/quick-card-create.js | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/surfaces/operations/sponsor-doc-event-time.js b/frontend/src/surfaces/operations/sponsor-doc-event-time.js index b3a6e791e..e92f7c21f 100644 --- a/frontend/src/surfaces/operations/sponsor-doc-event-time.js +++ b/frontend/src/surfaces/operations/sponsor-doc-event-time.js @@ -16,7 +16,8 @@ export function formatGuestEventTime(startIso, endIso, zone, fallbackDuration, i const validEnd = endIso && !Number.isNaN(end.getTime()) && end > start; const dateFormat = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: zone }); const timeFormat = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: zone }); - const offset = instant => new Intl.DateTimeFormat("en-GB", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find(part => part.type === "timeZoneName")?.value || zone; + const offsetFormat = new Intl.DateTimeFormat("en-GB", { timeZone: zone, timeZoneName: "short" }); + const offset = instant => offsetFormat.formatToParts(instant).find(part => part.type === "timeZoneName")?.value || zone; let range = `${timeFormat.format(start)} ${offset(start)}`; if (validEnd) { const sameDay = dateFormat.format(start) === dateFormat.format(end); @@ -33,4 +34,4 @@ export function dualZoneEventTime(startIso, endIso, guestZone, fallbackDuration) const rows = [`Berlin: ${berlin}`]; if (guestZone !== "Europe/Berlin") rows.push(`Your time: ${formatGuestEventTime(startIso, endIso, guestZone, fallbackDuration, false)}`); return `${rows.join("\n")}${duration ? `; ${duration} minutes` : ""}`; -} +} diff --git a/frontend/src/surfaces/tasks/quick-card-create.js b/frontend/src/surfaces/tasks/quick-card-create.js index 27eb15afc..538f4089f 100644 --- a/frontend/src/surfaces/tasks/quick-card-create.js +++ b/frontend/src/surfaces/tasks/quick-card-create.js @@ -87,7 +87,10 @@ export function createQuickCardForm(context) { const payload = await request(workApiUrl(`/api/sponsor-crm/directory/people?${query}`)); savedPeople = payload.items || []; savedGuest.replaceChildren(guestOption("Select a saved guest (optional)", "")); - for (const person of savedPeople) savedGuest.append(guestOption(`${person.name || "Saved guest"}${person.emails?.[0] ? ` (${person.emails[0]})` : " (email needed)"}`, person.id)); + for (const person of savedPeople) { + const address = person.emails?.[0] ? ` (${person.emails[0]})` : " (email needed)"; + savedGuest.append(guestOption(`${person.name || "Saved guest"}${address}`, person.id)); + } guestHint.textContent = savedPeople.length ? "Select a saved guest to reuse their contact details." : "No saved guests found."; } catch (error) { guestHint.textContent = `Could not find guests: ${error.message || "request failed"}`; } finally { findGuest.disabled = false; } From 9f11c9f2ac2a03660d6ac3f9de6dcd7fe40b9492 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Fri, 6 Nov 2026 23:11:25 +0100 Subject: [PATCH 10/10] Target accessible Template control in Card creation browser tests --- backend/e2e/create-card-template.spec.js | 4 ++-- backend/e2e/demo-create-card.spec.js | 4 ++-- backend/e2e/optional-task-groups.spec.js | 2 +- backend/e2e/runtime-template-readonly.spec.js | 2 +- backend/e2e/surface-feedback-states.spec.js | 11 ++++++----- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/e2e/create-card-template.spec.js b/backend/e2e/create-card-template.spec.js index d6d22d2c2..363eeddac 100644 --- a/backend/e2e/create-card-template.spec.js +++ b/backend/e2e/create-card-template.spec.js @@ -11,7 +11,7 @@ test('Create card requires a Template and opens the Card linked to it', async ({ await page.goto('/cards'); await page.getByRole('button', { name: 'Create card', exact: true }).click(); const form = page.locator('.quick-form-overlay'); - await expect(form.locator('.quick-form-select')).toBeEnabled(); + await expect(form.getByRole('combobox', { name: 'Template', exact: true })).toBeEnabled(); const title = `Synthetic templated Card ${Date.now()}`; await form.getByLabel('Card title').fill(title); @@ -19,7 +19,7 @@ test('Create card requires a Template and opens the Card linked to it', async ({ await expect(form).toContainText('Select a template.'); expect(posts).toHaveLength(0); - await form.locator('.quick-form-select').selectOption({ label: 'Synthetic Git-authored workflow' }); + await form.getByRole('combobox', { name: 'Template', exact: true }).selectOption({ label: 'Synthetic Git-authored workflow' }); const created = page.waitForResponse((response) => response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/cards'); await form.getByRole('button', { name: 'Create card' }).click(); diff --git a/backend/e2e/demo-create-card.spec.js b/backend/e2e/demo-create-card.spec.js index 96211bc6d..746b0c953 100644 --- a/backend/e2e/demo-create-card.spec.js +++ b/backend/e2e/demo-create-card.spec.js @@ -21,8 +21,8 @@ for (const [width, height] of [[1440, 900], [1280, 800], [375, 667], [390, 844]] const dialog = page.locator('.quick-form-panel'); await expect(dialog).toBeVisible(); await expect(dialog.getByLabel('Date', { exact: true })).toHaveValue(/\d{4}-\d{2}-\d{2}/); - await expect(dialog.locator('select')).toBeEnabled(); - expect(await dialog.locator('select option').count()).toBeGreaterThan(1); + await expect(dialog.getByRole('combobox', { name: 'Template', exact: true })).toBeEnabled(); + expect(await dialog.getByRole('combobox', { name: 'Template', exact: true }).locator('option').count()).toBeGreaterThan(1); await expect(dialog).not.toContainText('template available'); const field = await dialog.getByLabel('Card title').boundingBox(); const panel = await dialog.boundingBox(); diff --git a/backend/e2e/optional-task-groups.spec.js b/backend/e2e/optional-task-groups.spec.js index 28f750ddc..09bac2115 100644 --- a/backend/e2e/optional-task-groups.spec.js +++ b/backend/e2e/optional-task-groups.spec.js @@ -36,7 +36,7 @@ for (const includeOptional of [false, true]) { await page.goto(`/templates?templateId=${encodeURIComponent(template.id)}`); await page.locator('.runtime-template-projection').getByRole('button', { name: 'Start card' }).click(); const form = page.locator('.quick-form-overlay'); - await expect(form.locator('.quick-form-select')).toHaveValue(template.id); + await expect(form.getByRole('combobox', { name: 'Template', exact: true })).toHaveValue(template.id); const checkbox = form.getByRole('checkbox', { name: 'Include secondary tasks' }); await expect(checkbox).not.toBeChecked(); await expect(form.getByRole('checkbox')).toHaveCount(1); diff --git a/backend/e2e/runtime-template-readonly.spec.js b/backend/e2e/runtime-template-readonly.spec.js index f1049387c..2e6d6b8a5 100644 --- a/backend/e2e/runtime-template-readonly.spec.js +++ b/backend/e2e/runtime-template-readonly.spec.js @@ -54,7 +54,7 @@ test.describe('Git-authored runtime templates', () => { await projection.getByRole('button', { name: 'Start card' }).click(); const form = page.locator('.quick-form-overlay'); - await expect(form.locator('.quick-form-select')).toHaveValue(template.id); + await expect(form.getByRole('combobox', { name: 'Template', exact: true })).toHaveValue(template.id); await form.getByLabel('Card title').fill('Synthetic pinned card'); const createdResponse = page.waitForResponse((response) => ( response.request().method() === 'POST' diff --git a/backend/e2e/surface-feedback-states.spec.js b/backend/e2e/surface-feedback-states.spec.js index 1f8cc08b4..8d408df23 100644 --- a/backend/e2e/surface-feedback-states.spec.js +++ b/backend/e2e/surface-feedback-states.spec.js @@ -259,11 +259,12 @@ test.describe('issue 204 owning-surface feedback', () => { await page.getByRole('button', { name: 'Create card' }).click(); const cardForm = page.locator('.quick-form'); - await expect(cardForm.locator('select:not([disabled])')).toBeVisible(); - const templateOption = cardForm.locator('select option:not([value=""])').first(); - await expect(templateOption).toHaveCount(1); - await cardForm.locator('select').selectOption({ index: 1 }); - const cardTitle = cardForm.locator('input[type="text"]'); + const templateSelect = cardForm.getByRole('combobox', { name: 'Template', exact: true }); + await expect(templateSelect).toBeVisible(); + await expect(templateSelect).toBeEnabled(); + const selectedTemplate = await templateSelect.selectOption({ index: 1 }); + expect(selectedTemplate).toHaveLength(1); + const cardTitle = cardForm.getByRole('textbox', { name: /^Card title(?: \(optional\))?$/ }); await cardTitle.fill('Synthetic public-safe card'); await setFaults(context.request, [ { method: 'POST', path: '/api/cards', status: 409, remaining: 2 },