Skip to content
Open
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
6 changes: 5 additions & 1 deletion .github/workflows/validate-backend-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ jobs:
run: npm ci

- name: Install backend Playwright Chromium
run: npm exec --workspace dataops-backend -- playwright install --with-deps chromium
run: |
# The runner's Azure mirror can fail DNS before Playwright installs
# dependencies. Use Ubuntu's HTTPS archive directly for this job.
sudo sed -i 's|mirror+file:/etc/apt/apt-mirrors.txt|https://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
npm exec --workspace dataops-backend -- playwright install --with-deps chromium

- name: Run backend Playwright E2E
run: npm --prefix backend run test:e2e
Expand Down
60 changes: 60 additions & 0 deletions backend/src/events/titleQuality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** Deterministic spelling checks; topic meaning and breadth are reviewed by an operator. */
const BRAND_SPELLINGS = new Set(['dbt', 'dlt', 'pandas', 'scikit-learn']);
const SMALL = new Set('a an the and or of to in on for with at'.split(' '));
export const TITLE_CHECKLIST = {
meaningful: 'Names an agreed audience problem, skill or approach; not only a guest, employer or job title.',
broad: 'Still useful without incidental company or city context. Databricks FDE → Forward Deployed Engineering.',
consistent: 'The same topic is used in the episode, Card, hold, guest/prep documents, banners and public pages. Review existing copies; this check never publishes or renames them.',
};
export interface TitleReview {
title: string;
meaningful: true;
broad: true;
consistent: true;
note: string;
actor: string;
reviewedAt: string;
}
export function suggestedTitle(title: string, guests: string[] = []): string {
let first = true;
const names = guests.filter(Boolean).flatMap(guest => {
const start = title.indexOf(guest);
return start < 0 ? [] : [{ start, end: start + guest.length }];
});
return title.replace(/https?:\/\/\S+|`[^`]*`|[\p{L}\p{N}]+(?:[’'/-][\p{L}\p{N}]+)*|:/gu, (word, offset: number) => {
if (word === ':') { first = true; return word; }
if (names.some(name => offset >= name.start && offset < name.end)) { first = false; return word; }
if (/^https?:|^`/.test(word)) { first = false; return word; }
const start = first;
first = false;
if (SMALL.has(word.toLowerCase())) return start ? word[0].toUpperCase() + word.slice(1).toLowerCase() : word.toLowerCase();
if (BRAND_SPELLINGS.has(word)) return word;
// Acronyms, mixed-case brands, code and URL spellings are never rewritten.
if (/[A-Z].*[A-Z]|[a-z][A-Z]/.test(word) || word.includes('/')) return word;
return word.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('-');
});
}
export function checkTitle(title: unknown, guests: string[] = []) {
const text = typeof title === 'string' ? title.trim() : '';
const issues: Array<{ code: string; message: string }> = [];
const draft = /^(?:\[draft\]|draft\b|tbd\b|to be determined\b)/i.test(text);
if (!text) issues.push({ code: 'empty', message: 'Supply the agreed audience topic, or visibly mark the planning title Draft/TBD while the topic is missing.' });
if (draft) issues.push({ code: 'draft', message: 'Draft/TBD is allowed in planning. Agree a meaningful title before topic approval.' });
const suggested = suggestedTitle(text, guests);
if (text && !draft && text !== suggested) issues.push({ code: 'title_case', message: `Use Title Case: ${suggested}. Keep small words lowercase except first or after a colon; preserve names, acronyms and brands.` });
const prefix = /^(?:podcast(?: conversation)?|conversation|interview|webinar|workshop) with\s+/i;
const guestOnly = prefix.test(text) && (guests.length
? guests.some(guest => text.replace(prefix, '').toLowerCase() === guest.trim().toLowerCase())
: /^podcast conversation with\s+/i.test(text) && !/\b(?:about|on)\b|[:—]/i.test(text.replace(prefix, '')));
if (guestOnly || guests.some(guest => guest.trim().toLowerCase() === text.toLowerCase())) issues.push({ code: 'guest_only', message: 'A guest-only placeholder is not a topic. Record the missing subject and keep it Draft/TBD; do not invent a topic from the guest’s employer or role.' });
if (text.toLowerCase() === 'databricks fde') issues.push({ code: 'breadth_review', message: 'Known narrow-topic example: Databricks FDE. Review the agreed broader topic Forward Deployed Engineering; do not infer other topics from employers.' });
return { title: text, suggestedTitle: suggested, draft, issues, checklist: TITLE_CHECKLIST, reviewRequired: true,
planningAllowed: true, message: 'Planning saves are allowed. Topic approval requires the checklist and a reviewed meaningful title. No title is changed automatically.' };
}
export function titleReviewError(title: string, review: TitleReview | undefined, guests: string[] = []): string | null {
const check = checkTitle(title, guests);
const errors = check.issues.filter(issue => issue.code !== 'breadth_review');
if (errors.length) return errors.map(issue => issue.message).join(' ');
if (!review || review.title !== title || !review.meaningful || !review.broad || !review.consistent) return 'Review this exact title with dataops event approve-title <card> --meaningful --broad --consistent --note "review evidence" --expected-version <version>. Check the agreed topic and every title copy first.';
return null;
}
51 changes: 51 additions & 0 deletions backend/src/events/titleReview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { getCardConsistent, updateCard } from '../db/cards';
import { getPodcastEpisode, listPodcastEpisodes } from '../db/podcastEpisodes';
import { getTemplate } from '../db/templates';
import { getCalendarHold } from '../db/calendarHolds';
import { podcastEpisodeTitleValues, renderCardTitle } from '../cards/cardTitlePattern';
import { checkTitle, titleReviewError, type TitleReview } from './titleQuality';
import { EventConflictError } from './lifecycle';

export class TitleReviewInputError extends Error {}

/** Read-only: unlike event transition resolution this never upgrades old holds. */
export async function inspectEventTitle(client: DynamoDBDocumentClient, targetId: string) {
let card = await getCardConsistent(client, targetId);
let episode = card ? (await listPodcastEpisodes(client)).find(item => item.cardId === card!.id) : await getPodcastEpisode(client, targetId);
let hold = !card && !episode ? await getCalendarHold(client, targetId) : null;
if (!card) card = await getCardConsistent(client, episode?.cardId || hold?.cardId || '');
if (!card) throw new TitleReviewInputError('Event Card not found');
if (!episode) episode = (await listPodcastEpisodes(client)).find(item => item.cardId === card!.id);
if (!hold) hold = await getCalendarHold(client, card.eventTime?.eventKey || episode?.recordingHoldId || `hold_${card.id}`);
const title = episode?.title || hold?.topic || card.title || '';
const check = checkTitle(title, episode?.guests.map(guest => guest.name) || card.people || []);
const pattern = (await getTemplate(client, card.templateId))?.cardTitle;
const expectedCardTitle = episode && pattern ? renderCardTitle(pattern, podcastEpisodeTitleValues(episode)) : title;
const copies = { card: card.title || '', ...(episode ? { episode: episode.title || '' } : {}), ...(hold ? { holdTitle: hold.title, holdTopic: hold.topic } : {}) };
const mismatches: string[] = [];
if (card.title !== expectedCardTitle) mismatches.push('Card');
if (hold && (hold.title !== title || hold.topic !== title)) mismatches.push('hold title/topic');
const reviewError = titleReviewError(title, card.titleReview, episode?.guests.map(guest => guest.name) || card.people);
return { ...check, cardId: card.id, expectedVersion: card.version, copies, mismatches,
approved: !reviewError && !mismatches.length, reviewRequired: Boolean(reviewError || mismatches.length),
review: card.titleReview || null,
nextStep: mismatches.length ? `Reconcile ${mismatches.join(', ')} explicitly with the episode/Card and dataops event update. Check documents/public copies before approving; nothing is renamed or published by this check.` : reviewError,
};
}

export async function approveEventTitle(client: DynamoDBDocumentClient, targetId: string, input: Record<string, unknown>, actor: string) {
const check = await inspectEventTitle(client, targetId);
if (input.expectedVersion !== check.expectedVersion) throw new EventConflictError('Card changed; rerun title-check and use its expectedVersion');
for (const key of ['meaningful', 'broad', 'consistent']) {
if (input[key] !== true) throw new TitleReviewInputError(`Confirm ${key}: ${check.checklist[key as keyof typeof check.checklist]}`);
}
if (typeof input.note !== 'string' || !input.note.trim() || input.note.length > 1000) throw new TitleReviewInputError('A review note (1–1000 characters) describing the agreed topic and checked title copies is required');
const review: TitleReview = { title: check.title, meaningful: true, broad: true, consistent: true, note: input.note.trim(), actor, reviewedAt: new Date().toISOString() };
const error = check.issues.filter(issue => issue.code !== 'breadth_review').map(issue => issue.message).join(' ');
if (error) throw new TitleReviewInputError(error);
if (check.mismatches.length) throw new TitleReviewInputError(check.nextStep || 'Reconcile title copies before approval');
// Only this internal review is stored. No Google, email, document or public write.
await updateCard(client, check.cardId, { expectedVersion: check.expectedVersion, patch: { titleReview: review } });
return { ...check, expectedVersion: check.expectedVersion + 1, approved: true, reviewRequired: false, review, nextStep: null };
}
10 changes: 6 additions & 4 deletions backend/src/routes/calendarHolds.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { checkTitle } from '../events/titleQuality';
import { linkConfirmedHold, HoldLinkError } from '../calendar/linkConfirmedHold';
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import type { LambdaEvent, LambdaResponse } from '../types';
Expand Down Expand Up @@ -110,7 +111,7 @@ async function createRoute(event: LambdaEvent, client: DynamoDBDocumentClient):
: { googleEventIds: sync.eventIds, googleSync: { state: 'deferred' as const, reason: sync.reason } }),
};
await putCalendarHold(client, stored);
return json(201, { hold: stored });
return json(201, { hold: stored, titleQuality: checkTitle(stored.topic || stored.title) });
}

function rangeQuery(event: LambdaEvent): { from?: string; to?: string } | null {
Expand Down Expand Up @@ -258,7 +259,7 @@ async function confirmRoute(id: string, event: LambdaEvent, client: DynamoDBDocu
: { googleEventIds: sync.eventIds, googleSync: { state: 'deferred' as const, reason: sync.reason } }),
};
await putCalendarHold(client, stored);
return json(200, { hold: stored });
return json(200, { hold: stored, titleQuality: checkTitle(stored.topic || stored.title) });
}

async function releaseRoute(
Expand Down Expand Up @@ -288,7 +289,7 @@ async function releaseRoute(
: { googleSync: { state: 'deferred' as const, reason: sync.reason } }),
};
await putCalendarHold(client, stored);
return json(200, { hold: stored });
return json(200, { hold: stored, titleQuality: checkTitle(stored.topic || stored.title) });
}

async function moveRoute(
Expand Down Expand Up @@ -319,6 +320,7 @@ async function moveRoute(
actor: actor(event),
});
return json(200, {
titleQuality: checkTitle(result.event.renderValues.topic || result.event.renderValues.title),
hold: toCalendarHoldView(result),
event: result.event,
card: result.card,
Expand Down Expand Up @@ -381,7 +383,7 @@ async function moveRoute(
: { googleEventIds: sync.eventIds, googleSync: { state: 'deferred' as const, reason: sync.reason } }),
};
await putCalendarHold(client, stored);
return json(200, { hold: stored });
return json(200, { hold: stored, titleQuality: checkTitle(stored.topic || stored.title) });
}

async function expireRoute(event: LambdaEvent, client: DynamoDBDocumentClient): Promise<LambdaResponse> {
Expand Down
5 changes: 3 additions & 2 deletions backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { checkTitle } from '../events/titleQuality';
import { getSponsorDocWithAuthMaterial, decryptSponsorDocAccessCode } from '../db/sponsorDocs';
import { getEventRecord } from '../events/store';
import { loadGuestFirstContact } from '../events/guestFirstContact';
Expand Down Expand Up @@ -579,7 +580,7 @@ async function handleCollection(
const templateIdByType = await getTemplateIdByType(client);
const tasks = slots.eventSlots.length ? await listTasksByCardConsistent(client, created.card.id) : created.tasks;
const card = await projectCard(new TeamDirectory(client), created.card, tasks, templateIdByType);
return json(201, { card, tasks, ...slots });
return json(201, { card, tasks, ...slots, titleQuality: checkTitle(created.card.title, created.card.people) });
}

// Method not allowed
Expand Down Expand Up @@ -819,7 +820,7 @@ async function handleSingle(
}
}
const templateIdByType = await getTemplateIdByType(client);
return json(200, { card: await projectCard(new TeamDirectory(client), card, undefined, templateIdByType) });
return json(200, { card: await projectCard(new TeamDirectory(client), card, undefined, templateIdByType), ...(body.title !== undefined ? { titleQuality: checkTitle(body.title, card.people) } : {}) });
}

if (method === 'DELETE') {
Expand Down
22 changes: 22 additions & 0 deletions backend/src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { checkTitle } from '../events/titleQuality';
import { inspectEventTitle, approveEventTitle, TitleReviewInputError } from '../events/titleReview';
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import type { LambdaEvent, LambdaResponse } from '../types';
import { resolveInteractiveActor } from '../identity/actor';
Expand Down Expand Up @@ -29,6 +31,7 @@ async function formatEventResponse(
const templateIdByType = await getTemplateIdByType(client);
const projectedCard = await projectCard(directory, result.card, undefined, templateIdByType);
return {
titleQuality: checkTitle(result.event.renderValues.topic || result.event.renderValues.title),
event: result.event,
hold: toCalendarHoldView(result),
history: result.history,
Expand Down Expand Up @@ -93,6 +96,25 @@ export async function handleEventRoutes(
const actorId = resolved.actor.id || 'authenticated-operator';

try {
const titleMatch = path.match(/^\/api\/events(?:\/([^/]+))?\/(title-check|approve-title)\/?$/);
if (titleMatch) {
if (method !== 'POST') return json(405, { error: 'Method not allowed' });
const body = parseBody(event.body);
if (!body) return json(400, { error: 'Invalid JSON body' });
const targetId = titleMatch[1] ? decodeURIComponent(titleMatch[1]) : '';
try {
if (titleMatch[2] === 'title-check') return json(200, targetId
? await inspectEventTitle(client, targetId) : checkTitle(body.title, Array.isArray(body.guests) ? body.guests.filter((guest: unknown) => typeof guest === 'string') : []));
if (!targetId) return json(400, { error: 'An event Card, episode or hold ID is required' });
return json(200, await approveEventTitle(client, targetId, body, actorId));
} catch (error) {
if (error instanceof EventConflictError) throw error;
if ((error as Error).name === 'CardVersionConflictError') return json(409, { error: 'Card changed; rerun title-check before approval' });
if (error instanceof TitleReviewInputError) return json(422, { error: error.message });
throw error;
}
}

// POST /api/events/poll or POST /api/events/:targetId/poll
const pollMatch = path.match(/^\/api\/events(?:\/([^/]+))?\/poll\/?$/);
if (pollMatch) {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/podcastEpisodes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { checkTitle } from '../events/titleQuality';
/**
* /api/podcast-episodes: the podcast episode register.
*
Expand Down Expand Up @@ -354,6 +355,7 @@ const presented = async (client: DynamoDBDocumentClient, episode: PodcastEpisode
const authority = await resolveEpisodeEventAuthority(client, episode);
return {
...episode,
titleQuality: checkTitle(episode.title, episode.guests.map(guest => guest.name)),
...(authority
? {
recordingDate: authority.date,
Expand Down
20 changes: 20 additions & 0 deletions backend/src/routes/taskValidation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { inspectEventTitle, TitleReviewInputError } from '../events/titleReview';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

import { getArtifact, listArtifacts } from '../db/artifacts';
Expand Down Expand Up @@ -625,13 +626,30 @@ export async function validateLinkedCardRequirement(
return null;
}

/** A title-dependent approval/publication Task opts in through validation.titleQuality. */
export async function validateTaskTitleQuality(client: DynamoDBDocumentClient, task: Record<string, unknown>): Promise<string | null> {
const validation = task.validation;
if (!validation || typeof validation !== 'object' || Array.isArray(validation)
|| !['topic-approval', 'publication'].includes(String((validation as Record<string, unknown>).titleQuality))) return null;
if (typeof task.cardId !== 'string' || !task.cardId) return 'Title review requires a linked event Card';
try {
const check = await inspectEventTitle(client, task.cardId);
return check.approved ? null : `Cannot complete this title-dependent Task: ${check.nextStep} Run dataops event title-check ${task.cardId}.`;
} catch (error) {
if (error instanceof TitleReviewInputError) return error.message;
throw error;
}
}

export async function validateDoneProof(
client: DynamoDBDocumentClient,
id: string,
existing: Task,
updates: Record<string, unknown>
): Promise<string | null> {
const taskData = { ...existing, ...updates };
const titleError = await validateTaskTitleQuality(client, taskData);
if (titleError) return titleError;
if (skipClosureSuppresses(taskData, 'proof')) {
return null;
}
Expand Down Expand Up @@ -670,6 +688,8 @@ export async function validateDoneProof(
}

export async function validateDoneProofOnCreate(client: DynamoDBDocumentClient, taskData: Record<string, unknown>): Promise<string | null> {
const titleError = await validateTaskTitleQuality(client, taskData);
if (titleError) return titleError;
const requiredLinkName = taskData.requiredLinkName as string | undefined;
if (requiredLinkName && !isNonEmptyString(taskData.link) && !skipClosureSuppresses(taskData, 'requiredLink', requiredLinkName)) {
return `Cannot mark task as done: required link '${requiredLinkName}' is not filled`;
Expand Down
1 change: 1 addition & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export interface CardCalendarInvite {
}

export interface Card {
titleReview?: import('./events/titleQuality').TitleReview;
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. */
Expand Down
Loading
Loading