diff --git a/backend/src/routes/sponsorDocs.ts b/backend/src/routes/sponsorDocs.ts index 25fe66b9..33460013 100644 --- a/backend/src/routes/sponsorDocs.ts +++ b/backend/src/routes/sponsorDocs.ts @@ -1,5 +1,6 @@ import { guestPortalSlots, recordGuestPortalTimeChoice } from '../events/guestIntakePortal'; import { hydrateGuestEventSlotOptions } from '../sponsorDocs/templates'; +import { recordPortalDocEdit } from '../sponsorDocs/portalEditTask'; import { projectDocumentEvent, readDocumentEventMetadata } from '../sponsorDocs/eventMetadata'; import { randomUUID } from 'node:crypto'; import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; @@ -1026,6 +1027,10 @@ export async function handleSponsorDocRoutes( status: body.status as SponsorDocStatus | undefined, actorKind: 'sponsor', }); + if (updated) { + const docUrl = `${sponsorDocPortalBaseUrl(event)}${sponsorDocPathForTemplate(updated.template)}/${encodeURIComponent(updated.id)}`; + await recordPortalDocEdit(client, { before: verified.doc, after: updated, docUrl }); + } 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, { diff --git a/backend/src/sponsorDocs/portalEditTask.ts b/backend/src/sponsorDocs/portalEditTask.ts new file mode 100644 index 00000000..59ad07e8 --- /dev/null +++ b/backend/src/sponsorDocs/portalEditTask.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto'; +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getCardConsistent } from '../db/cards'; +import { + createTask, + getTaskConsistent, + listTasksByCardConsistent, + listTasksByStatus, + TaskVersionConflictError, + updateTask, +} from '../db/tasks'; +import { resolveDefaultAssigneeId } from '../db/workspaceSettings'; +import type { SponsorDoc } from '../db/sponsorDocs'; +import type { Task } from '../types'; +import { getSponsorDocTemplate } from './templates'; + +export const PORTAL_EDIT_TASK_SOURCE = 'portal-doc-edit'; +const ACTOR = 'system:portal-doc-edit'; +const VALUE_EXCERPT = 160; +const OPEN_STATUSES = ['todo', 'waiting']; + +export interface PortalDocChange { + field: string; + before: string; + after: string; +} + +function excerpt(value: unknown): string { + const text = String(value ?? '').replace(/\s+/g, ' ').trim(); + return text.length > VALUE_EXCERPT ? `${text.slice(0, VALUE_EXCERPT - 1)}…` : text; +} + +/** Content fields and status that differ between two versions of one doc. */ +export function diffPortalDoc(before: SponsorDoc, after: SponsorDoc): PortalDocChange[] { + const changes: PortalDocChange[] = []; + const keys = new Set([...Object.keys(before.content), ...Object.keys(after.content)]); + for (const key of keys) { + const was = String(before.content[key] ?? ''); + const now = String(after.content[key] ?? ''); + if (was !== now) changes.push({ field: key, before: excerpt(was), after: excerpt(now) }); + } + if (before.status !== after.status) changes.push({ field: 'status', before: before.status, after: after.status }); + return changes; +} + +function describeChanges(changes: PortalDocChange[]): string { + return changes + .map((change) => `- ${change.field}: ${change.before ? `"${change.before}"` : '(empty)'} → ${change.after ? `"${change.after}"` : '(empty)'}`) + .join('\n'); +} + +async function findOpenTask(client: DynamoDBDocumentClient, docId: string, cardId: string | null): Promise { + const tag = `doc:${docId}`; + const candidates = cardId + ? await listTasksByCardConsistent(client, cardId) + : (await Promise.all(OPEN_STATUSES.map((status) => listTasksByStatus(client, status)))).flat(); + return candidates.find((task) => + task.source === PORTAL_EDIT_TASK_SOURCE + && OPEN_STATUSES.includes(task.status) + && (task.tags || []).includes(tag)) || null; +} + +/** + * Surface an external (guest or sponsor) edit to a shared portal doc as one + * open Task for the default owner team. A burst of edits appends to the open + * Task; a new Task appears only once the previous one is closed. + */ +export async function recordPortalDocEdit( + client: DynamoDBDocumentClient, + input: { before: SponsorDoc; after: SponsorDoc; docUrl: string }, +): Promise { + const { before, after, docUrl } = input; + const changes = diffPortalDoc(before, after); + if (changes.length === 0) return null; + + const card = await getCardConsistent(client, after.cardId); + const activeCardId = card && card.status === 'active' ? card.id : null; + const label = getSponsorDocTemplate(after.template).shortLabel; + const cardTitle = card?.title || after.title; + const now = new Date().toISOString(); + const noteText = `${after.lastEditedByName || 'External editor'} edited ${label} "${after.title}" (${docUrl}):\n${describeChanges(changes)}`; + + for (let attempt = 0; attempt < 3; attempt += 1) { + const open = await findOpenTask(client, after.id, activeCardId); + if (!open) break; + try { + return await updateTask(client, open.id, { + currentTask: open, + expectedVersion: open.version, + patch: { + comment: noteText, + notesLog: [...(open.notesLog || []), { id: randomUUID(), text: noteText, authorId: ACTOR, createdAt: now }], + }, + historyEvents: [{ + id: randomUUID(), taskId: open.id, ...(activeCardId ? { cardId: activeCardId } : {}), + action: 'noted', actorId: ACTOR, note: noteText, createdAt: now, + }], + actorId: ACTOR, + triggerKind: 'portal-doc-edit', + }); + } catch (error) { + if (!(error instanceof TaskVersionConflictError)) throw error; + const stillThere = await getTaskConsistent(client, open.id); + if (!stillThere || !OPEN_STATUSES.includes(stillThere.status)) break; + } + } + + const assigneeId = await resolveDefaultAssigneeId(client); + return createTask(client, { + description: `Review portal doc edit: ${label} "${after.title}" (${cardTitle}${activeCardId ? '' : `, card ${after.cardId}`})`, + date: now.slice(0, 10), + status: 'todo', + source: PORTAL_EDIT_TASK_SOURCE, + tags: ['portal-edit', `doc:${after.id}`, `card:${after.cardId}`], + link: docUrl, + comment: noteText, + notesLog: [{ id: randomUUID(), text: noteText, authorId: ACTOR, createdAt: now }], + ...(assigneeId ? { assigneeId } : {}), + ...(activeCardId ? { cardId: activeCardId } : {}), + }); +} diff --git a/backend/tests/portal-doc-edit-task.test.ts b/backend/tests/portal-doc-edit-task.test.ts new file mode 100644 index 00000000..18f254c2 --- /dev/null +++ b/backend/tests/portal-doc-edit-task.test.ts @@ -0,0 +1,155 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getClient } from '../src/db/client'; +import { createTables, startLocal, stopLocal } from '../scripts/local-dynamodb'; +import { createCard } from '../src/db/cards'; +import { createTask, getTaskConsistent, listTasksByCardConsistent, listTasksByStatus, updateTask } from '../src/db/tasks'; +import { createUser } from '../src/db/users'; +import { createSession } from '../src/db/sessions'; +import { createPodcastEpisode } from '../src/db/podcastEpisodes'; +import { getWorkspaceSettings, setDefaultAssignee } from '../src/db/workspaceSettings'; +import { PORTAL_EDIT_TASK_SOURCE } from '../src/sponsorDocs/portalEditTask'; +import { templateLink } from './helpers/cardTemplate'; + +describe('portal doc edits create one open Task for the owner team', () => { + let client: DynamoDBDocumentClient; + let handler: typeof import('../src/handler').handler; + let staffUserId: string; + let staffToken: string; + + before(async () => { + const port = await startLocal(); + client = await getClient(port); + await createTables(client); + process.env.IS_LOCAL = 'true'; + handler = (await import('../src/handler')).handler; + const user = await createUser(client, { email: 'orin@datatalks.club', name: 'Orin', passwordHash: 'unused', role: 'admin' }); + staffUserId = user.id; + staffToken = (await createSession(client, staffUserId)).token; + const settings = await getWorkspaceSettings(client); + await setDefaultAssignee(client, { defaultAssigneeId: staffUserId, expectedVersion: settings.version }); + }); + + after(async () => { + await stopLocal(); + delete process.env.IS_LOCAL; + }); + + const staffReq = (method: string, path: string, body?: unknown) => handler({ + httpMethod: method, path, + headers: { authorization: `Bearer ${staffToken}`, 'x-user-id': staffUserId, host: 'ops.dtcdev.click', 'x-forwarded-proto': 'https' }, + queryStringParameters: null, body: body === undefined ? null : JSON.stringify(body), + }, {}); + + const externalPut = (docId: string, code: string, content: Record, status?: string) => handler({ + httpMethod: 'PUT', path: `/api/sponsor-docs/${docId}`, + headers: { host: 'ops.dtcdev.click', 'x-forwarded-proto': 'https', 'x-forwarded-for': '198.51.100.7', 'x-sponsor-doc-anonymous': 'true', 'x-sponsor-doc-code': code }, + queryStringParameters: null, body: JSON.stringify({ content, ...(status ? { status } : {}) }), + }, {}); + + const portalTasks = async (cardId: string) => + (await listTasksByCardConsistent(client, cardId)).filter((t) => t.source === PORTAL_EDIT_TASK_SOURCE); + + async function close(taskId: string) { + const task = await getTaskConsistent(client, taskId); + await updateTask(client, taskId, { currentTask: task!, expectedVersion: task!.version, patch: { status: 'done', completedBy: staffUserId, completedAt: new Date().toISOString() } }); + } + + async function createDoc(cardId: string, template: string) { + const res = await staffReq('POST', '/api/sponsor-docs', { cardId, template, autoSendEmail: false }); + assert.strictEqual(res.statusCode, 201, res.body); + return JSON.parse(res.body).doc as { id: string; accessCode: string; docUrl: string; content: Record }; + } + + it('sponsor doc: burst of edits -> one open Task; edit after close -> new Task', async () => { + const card = await createCard(client, { + ...templateLink(), title: '[Newsletter] (02 NOV 2026) #310 - Acme', anchorDate: '2026-11-02', sponsor: 'Acme', + cardLinks: [{ name: 'Sponsorship document', url: '' }], + }); + await createTask(client, { description: 'Prepare newsletter', date: '2026-11-02', cardId: card.id }); + const doc = await createDoc(card.id, 'newsletter-main'); + assert.strictEqual((await portalTasks(card.id)).length, 0, 'staff creation raises nothing'); + + assert.strictEqual((await externalPut(doc.id, doc.accessCode, { subjectLine: 'First subject' })).statusCode, 200); + let tasks = await portalTasks(card.id); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].assigneeId, staffUserId); + assert.strictEqual(tasks[0].status, 'todo'); + assert.strictEqual(tasks[0].link, doc.docUrl); + assert.ok(!tasks[0].link!.includes('code='), 'task link never carries the access code'); + assert.match(tasks[0].description, /Acme/); + assert.match(tasks[0].comment!, /subjectLine: \(empty\) → "First subject"/); + + // Same value again changes nothing -> no note, no task + await externalPut(doc.id, doc.accessCode, { subjectLine: 'First subject' }); + // Burst + await externalPut(doc.id, doc.accessCode, { subjectLine: 'Second subject' }); + await externalPut(doc.id, doc.accessCode, { previewText: 'Preview' }); + tasks = await portalTasks(card.id); + assert.strictEqual(tasks.length, 1, 'burst stays on one open task'); + const notes = tasks[0].notesLog!.map((n) => n.text); + assert.strictEqual(notes.length, 3); + assert.match(notes[1], /"First subject" → "Second subject"/); + assert.match(notes[2], /previewText/); + + await close(tasks[0].id); + const third = await externalPut(doc.id, doc.accessCode, { subjectLine: 'Third subject' }); + assert.strictEqual(third.statusCode, 200, third.body); + tasks = await portalTasks(card.id); + assert.strictEqual(tasks.length, 2, 'edit after close opens a new task'); + assert.strictEqual(tasks.filter((t) => t.status === 'todo').length, 1); + }); + + it('staff edits do not raise a task', async () => { + const card = await createCard(client, { ...templateLink(), title: '[Newsletter] (09 NOV 2026) #311 - Globex', anchorDate: '2026-11-09', sponsor: 'Globex', cardLinks: [{ name: 'Sponsorship document', url: '' }] }); + const doc = await createDoc(card.id, 'newsletter-main'); + const res = await staffReq('PUT', `/api/sponsor-docs/${doc.id}`, { content: { subjectLine: 'Staff wording' } }); + assert.strictEqual(res.statusCode, 200, res.body); + assert.strictEqual((await portalTasks(card.id)).length, 0); + }); + + it('guest doc (podcast episode brief): edits raise one task naming doc, card and fields', async () => { + const card = await createCard(client, { ...templateLink(), title: '[Podcast] 2026-12-01 - Jane Doe', anchorDate: '2026-12-01', people: ['Jane Doe'] }); + await createPodcastEpisode(client, { title: 'Shipping ML features', guests: [{ name: 'Jane Doe' }], status: 'booked', recordingDate: '2026-12-01', cardId: card.id }); + const doc = await createDoc(card.id, 'podcast-episode-brief'); + const fieldKey = Object.keys(doc.content).find((key) => doc.content[key] === '' ) as string; + assert.ok(fieldKey, 'brief has an editable field'); + + assert.strictEqual((await externalPut(doc.id, doc.accessCode, { [fieldKey]: 'Guest wrote this' })).statusCode, 200); + await externalPut(doc.id, doc.accessCode, { [fieldKey]: 'Guest rewrote this' }); + const tasks = await portalTasks(card.id); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].cardId, card.id); + assert.match(tasks[0].description, /Jane Doe/); + assert.ok(tasks[0].tags!.includes(`doc:${doc.id}`)); + assert.strictEqual(tasks[0].link, doc.docUrl); + assert.match(tasks[0].notesLog!.map((n) => n.text).join('\n'), new RegExp(`${fieldKey}: "Guest wrote this" → "Guest rewrote this"`)); + }); + + it('different docs on one card keep separate tasks', async () => { + const card = await createCard(client, { ...templateLink(), title: '[Podcast] 2026-12-08 - Max Roe', anchorDate: '2026-12-08', people: ['Max Roe'] }); + await createPodcastEpisode(client, { title: 'Data contracts', guests: [{ name: 'Max Roe' }], status: 'booked', recordingDate: '2026-12-08', cardId: card.id }); + const a = await createDoc(card.id, 'podcast-episode-brief'); + const b = await createDoc(card.id, 'podcast'); + const key = (d: { content: Record }) => Object.keys(d.content).find((k) => d.content[k] === '') as string; + await externalPut(a.id, a.accessCode, { [key(a)]: 'x' }); + const resB = await externalPut(b.id, b.accessCode, { [key(b)]: 'y' }); + assert.strictEqual(resB.statusCode, 200, resB.body); + assert.strictEqual((await portalTasks(card.id)).length, 2); + }); + + it('card archived since the last task closed: edit still opens a task naming the card', async () => { + const card = await createCard(client, { ...templateLink(), title: '[Newsletter] (16 NOV 2026) #312 - Initech', anchorDate: '2026-11-16', sponsor: 'Initech', cardLinks: [{ name: 'Sponsorship document', url: '' }] }); + const only = await createTask(client, { description: 'Only task', date: '2026-11-16', cardId: card.id }); + const doc = await createDoc(card.id, 'newsletter-main'); + await close(only.id); // archives the card + assert.strictEqual((await externalPut(doc.id, doc.accessCode, { subjectLine: 'Late edit' })).statusCode, 200); + await externalPut(doc.id, doc.accessCode, { subjectLine: 'Later edit' }); + const open = (await listTasksByStatus(client, 'todo')).filter((t) => t.source === PORTAL_EDIT_TASK_SOURCE && t.tags?.includes(`doc:${doc.id}`)); + assert.strictEqual(open.length, 1, 'cardless dedupe finds the open task'); + assert.ok(open[0].tags!.includes(`card:${card.id}`)); + assert.match(open[0].description, /Initech/); + assert.strictEqual(open[0].notesLog!.length, 2); + }); +});