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
5 changes: 5 additions & 0 deletions backend/src/routes/sponsorDocs.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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, {
Expand Down
121 changes: 121 additions & 0 deletions backend/src/sponsorDocs/portalEditTask.ts
Original file line number Diff line number Diff line change
@@ -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<Task | null> {
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<Task | null> {
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 } : {}),
});
}
155 changes: 155 additions & 0 deletions backend/tests/portal-doc-edit-task.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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<string, string> };
}

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<string, string> }) => 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);
});
});