Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/e2e/create-card-template.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ 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);

await form.getByRole('button', { name: 'Create card' }).click();
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();
Expand Down
4 changes: 2 additions & 2 deletions backend/e2e/demo-create-card.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion backend/e2e/optional-task-groups.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion backend/e2e/runtime-template-readonly.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 6 additions & 5 deletions backend/e2e/surface-feedback-states.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion backend/src/calendar/holdOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/calendar/holds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion backend/src/calendar/linkConfirmedHold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
64 changes: 47 additions & 17 deletions backend/src/cards/createCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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<ReturnType<typeof prepareGuestIntake>>;
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'];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, number> | 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',
Expand Down
18 changes: 15 additions & 3 deletions backend/src/db/sponsorDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ function cleanSponsorDoc(item: Record<string, unknown> | undefined): {
}

export interface CreateSponsorDocInput {
/** Internal durable orchestration identity. */
id?: string;
cardId: string;
template: SponsorDocTemplateKey;
title: string;
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -324,7 +336,7 @@ export async function updateSponsorDoc(
const nextTemplate = input.actorKind === 'staff' && input.template ? input.template : doc.template;
let nextContent: Record<string, string>;
if (nextTemplate !== doc.template) {
const carried: Record<string, unknown> = { ...doc.content, ...(input.content || {}) };
const carried: Record<string, unknown> = { ...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());
Expand Down
7 changes: 7 additions & 0 deletions backend/src/db/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,7 @@ async function createCardFromTemplate(
selectedGroups?: string[],
companions: (card: Card) => PlannedWrite[] = () => [],
counts?: Record<string, number>,
extraTasks?: (card: Card) => Task[],
): Promise<{ card: Card; tasks: Task[] }> {
const resolved = await resolveTemplateConditionalValidation(client, template, anchorDate);
const cardCounts = cardCountValues(resolved, counts);
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions backend/src/docs/frontend-assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions backend/src/events/guestContact.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => 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<ResolvedGuestContact> {
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<string, unknown>).kind))) {
throw new GuestContactInputError('guestReference must identify a saved CRM person or contact');
}
references.push((ref as Record<string, unknown>).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] } };
}
Loading