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
12 changes: 4 additions & 8 deletions backend/src/calendar/cardInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getTemplate } from '../db/templates';
import { getCalendarHold, listCalendarHolds, putCalendarHold } from '../db/calendarHolds';
import { calendarsMissingInvite, taskRequiresCalendarInvite, makeTaskHistoryEvent } from '../routes/taskValidation';
import { dateFromTemplateOffset } from '../templates/cardTemplateProjection';
import { todayIn } from '../relativeDates';
import { confirmedHoldInvite, confirmedTitle, holdGuestAttendees, isPodcastInviteTitle, parseHoldDocumentUrl, podcastInviteTitle, HOLD_ATTENDEE_DENY_LIST, type CalendarHold } from './holds';
import { listPodcastEpisodes, type PodcastEpisode } from '../db/podcastEpisodes';
import { recordTemplateTypes } from '../cards/cardTitlePattern';
Expand Down Expand Up @@ -352,7 +353,7 @@ export async function syncCardInvite(
if (linkedHold) {
const preservedTopic = linkedHold.topic || (podcastTitle && linkedHold.title && !isPodcastInviteTitle(linkedHold.title) ? confirmedTitle(linkedHold.title) : undefined);
storedHold = {
...linkedHold, ...interval, cardId: card.id, title: confirmedTitle(inviteTitle), ...(preservedTopic ? { topic: preservedTopic } : {}), date: new Intl.DateTimeFormat('en-CA', { timeZone: interval.timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(interval.startIso)),
...linkedHold, ...interval, cardId: card.id, title: confirmedTitle(inviteTitle), ...(preservedTopic ? { topic: preservedTopic } : {}), date: todayIn(interval.timeZone, new Date(interval.startIso)),
googleTitle: confirmedTitle(inviteTitle), guestRef: guests[0], ...(documentUrl ? { documentUrl } : {}),
status: 'confirmed', confirmedAt: linkedHold.confirmedAt || new Date().toISOString(),
googleEventIds: sync.eventIds, googleSync: { state: sync.state, ...(sync.state === 'deferred' ? { reason: sync.reason } : {}) },
Expand Down Expand Up @@ -554,11 +555,6 @@ export async function unlinkCardInvite(
return { card: updated, removed };
}

/** The event's local calendar date in its own time zone. */
function localDate(iso: string, timeZone: string): string {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso));
}

/**
* `cards time` dates an undated ("date TBD") event Card, and keeps a Card
* whose date followed its invitation on the invitation's day: the Card date
Expand All @@ -569,8 +565,8 @@ export async function adoptEventDate(
client: DynamoDBDocumentClient, card: Card, previous: Card['eventTime'],
): Promise<{ card: Card; movedTasks: number }> {
if (!card.eventTime || card.eventTime.sync !== 'synced') return { card, movedTasks: 0 };
const date = localDate(card.eventTime.startIso, card.eventTime.timeZone);
const followed = !card.anchorDate || (previous && card.anchorDate === localDate(previous.startIso, previous.timeZone));
const date = todayIn(card.eventTime.timeZone, new Date(card.eventTime.startIso));
const followed = !card.anchorDate || (previous && card.anchorDate === todayIn(previous.timeZone, new Date(previous.startIso)));
if (!followed || card.anchorDate === date) return { card, movedTasks: 0 };
const updated = await updateCardAdditive(client, card, () => ({ anchorDate: date }));
let movedTasks = 0;
Expand Down
11 changes: 3 additions & 8 deletions backend/src/db/apiTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

import { usesLocalTransactionEmulation } from './client';
import { cleanItem as cleanDbItem } from './cleanItem';
import { TABLE_SESSIONS } from './tableNames';
import type { ApiToken, ApiTokenSummary } from '../types';

Expand All @@ -21,12 +22,6 @@ function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

function cleanItem<T>(item: Record<string, unknown> | undefined): T | null {
if (!item) return null;
const { PK, SK, ...rest } = item;
return rest as unknown as T;
}

function tokenKey(id: string) {
return { PK: `APITOKEN#${id}`, SK: `APITOKEN#${id}` };
}
Expand Down Expand Up @@ -55,7 +50,7 @@ async function readApiTokenById(
): Promise<ApiToken | null> {
try {
const result = await client.send(new GetCommand({ TableName: TABLE_SESSIONS, Key: tokenKey(id) }));
return cleanItem<ApiToken>(result.Item as Record<string, unknown> | undefined);
return cleanDbItem<ApiToken>(result.Item as Record<string, unknown> | undefined);
} catch (error: unknown) {
if (isMissingTable(error)) return null;
throw error;
Expand Down Expand Up @@ -192,7 +187,7 @@ export async function listApiTokens(
throw error;
}
return items
.map((item) => cleanItem<ApiTokenSummary>(item))
.map((item) => cleanDbItem<ApiTokenSummary>(item))
.filter((item): item is ApiTokenSummary => Boolean(item))
.filter((item) => Date.parse(item.expiresAt) > now.getTime())
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
Expand Down
10 changes: 2 additions & 8 deletions backend/src/db/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@aws-sdk/lib-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

import { cleanItem as cleanDbItem } from './cleanItem';
import { TABLE_SESSIONS } from './tableNames';
import type { Session } from '../types';

Expand All @@ -13,14 +14,7 @@ export interface BrowserSessionOptions {
lifetimeSeconds: number;
}

/**
* Strip DynamoDB key attributes (PK, SK) from an item.
*/
function cleanItem(item: Record<string, unknown> | undefined): Session | null {
if (!item) return null;
const { PK, SK, ...rest } = item;
return rest as unknown as Session;
}
const cleanItem = cleanDbItem<Session>;

/** Lifetime of a bearer session from `createSession` (local e2e and tests only). */
const BEARER_SESSION_LIFETIME_SECONDS = 30 * 24 * 3600;
Expand Down
10 changes: 2 additions & 8 deletions backend/src/podcast/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { isRecord } from '../records';
import { todayIn } from '../relativeDates';

export type PodcastPublication = {
audioSchedule?: { scheduledAt: string; timezone: string; observedAt: string; proofUrl?: string };
Expand Down Expand Up @@ -73,14 +74,7 @@ export function validTimezone(value: unknown): boolean {

/** YYYY-MM-DD of `utcInstant` as read on the wall clock of `timezone`. */
export function localDate(utcInstant: string, timezone: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(utcInstant));
const part = (type: string) => parts.find((entry) => entry.type === type)?.value;
return `${part('year')}-${part('month')}-${part('day')}`;
return todayIn(timezone, new Date(utcInstant));
}

type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
Expand Down
45 changes: 3 additions & 42 deletions backend/src/podcast/releaseSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import { localDate, validTimezone } from "./publication";
import { civilToUtc, todayIn } from "../relativeDates";
import type { PodcastEpisode } from "../db/podcastEpisodes";

export interface PodcastReleaseCadence {
Expand Down Expand Up @@ -95,11 +96,6 @@ export function normalizeEpisodesPerSeason(value: unknown): number {
: DEFAULT_PODCAST_EPISODES_PER_SEASON;
}

/** YYYY-MM-DD of `now` on the wall clock of `timezone`. */
export function todayInTimezone(timezone: string, now = new Date()): string {
return localDate(now.toISOString(), timezone);
}

/** ISO weekday (Mon 1 .. Sun 7) of a Berlin-calendar `date` read in `timezone`. */
function weekdayOf(date: string, timezone: string): number {
const weekday = new Intl.DateTimeFormat("en-US", {
Expand All @@ -115,41 +111,6 @@ function addDays(date: string, days: number): string {
return parsed.toISOString().slice(0, 10);
}

function timezoneOffsetMs(timezone: string, utcMs: number): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(new Date(utcMs));
const part = (type: string) => parts.find((entry) => entry.type === type)?.value;
const wallAsUtc = Date.UTC(
Number(part("year")),
Number(part("month")) - 1,
Number(part("day")),
Number(part("hour") === "24" ? "00" : part("hour")),
Number(part("minute")),
Number(part("second")),
);
return wallAsUtc - utcMs;
}

/**
* The UTC instant of `time` on `date` in `timezone`, DST-aware: the offset is
* resolved for that actual date, so a 19:00 Berlin slot is 17:00Z in summer
* and 18:00Z in winter.
*/
export function zonedTimeToUtc(date: string, time: string, timezone: string): string {
const wallAsUtc = Date.parse(`${date}T${time}:00Z`);
let utc = wallAsUtc - timezoneOffsetMs(timezone, wallAsUtc);
utc = wallAsUtc - timezoneOffsetMs(timezone, utc);
return new Date(utc).toISOString();
}

/**
* Calendar dates already held: the `releaseDate` or the local date of the
* Spotify schedule of every non-cancelled episode.
Expand Down Expand Up @@ -222,7 +183,7 @@ export function nextReleaseSlots(input: {
const episodesPerSeason = input.episodesPerSeason ?? DEFAULT_PODCAST_EPISODES_PER_SEASON;
const count = input.count ?? 4;
const taken = takenReleaseDates(input.episodes);
const fromDate = todayInTimezone(cadence.timezone, input.now ?? new Date());
const fromDate = todayIn(cadence.timezone, input.now ?? new Date());
const dates = freeSlotDates(fromDate, count, cadence, taken);
const numbered = nextEpisodeNumber(input.episodes, episodesPerSeason);
return {
Expand All @@ -233,7 +194,7 @@ export function nextReleaseSlots(input: {
date,
localTime: cadence.time,
timezone: cadence.timezone,
utcInstant: zonedTimeToUtc(date, cadence.time, cadence.timezone),
utcInstant: civilToUtc(date, cadence.time, cadence.timezone),
})),
takenDates: [...taken].sort(),
};
Expand Down
7 changes: 7 additions & 0 deletions backend/src/relativeDates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export function addDays(day: string, days: number): string {
return date.toISOString().slice(0, 10);
}

/**
* Kept separate from sponsorCrm/model.realDate: same YYYY-MM-DD contract, but
* this roundtrip through `Date.UTC` components rejects four-digit years
* 0000-0099 (Date.UTC maps them onto 1900-1999) where realDate's parse-based
* roundtrip accepts them. Task dates want the stricter gate; nothing may
* resolve a relative word onto a year-0099 date.
*/
export function isCalendarDate(value: unknown): value is string {
if (typeof value !== 'string') return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
Expand Down
10 changes: 1 addition & 9 deletions backend/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ import { handleCampaignCalendarRoutes } from './routes/campaignCalendar';
import { handleDocumentRoutes } from './routes/documents';
import { handleDocumentReviewRoutes, isAnonymousDocumentReviewRequest } from './documents/reviews';
import { isAnonymousNewsletterPreviewRequest } from './newsletter/markdownPreview';
import { jsonResponse } from './http';
import type { LambdaEvent, LambdaResponse } from './types';
import { recordRequestError } from './requestErrorLog';

const JSON_HEADERS: Record<string, string> = { 'Content-Type': 'application/json' };
let cachedPortalSecret: string | null | undefined;

// Routes that do NOT require authentication
Expand Down Expand Up @@ -150,14 +150,6 @@ async function portalTrustedUserId(event: LambdaEvent): Promise<string | null> {
return headerValue(event.headers, 'x-user-id') || 'portal-admin';
}

function jsonResponse(statusCode: number, body: unknown): LambdaResponse {
return {
statusCode,
headers: JSON_HEADERS,
body: typeof body === 'string' ? body : JSON.stringify(body),
};
}

function decodeBase64Body(event: LambdaEvent): void {
if (!event.isBase64Encoded || typeof event.body !== 'string') return;
event.body = Buffer.from(event.body, 'base64').toString('binary');
Expand Down
6 changes: 1 addition & 5 deletions backend/src/routes/apiTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { API_TOKEN_SCOPES, parseApiTokenScopes } from '../auth/tokenScopes';
import { deleteApiToken, createApiToken, listApiTokens, projectApiToken } from '../db/apiTokens';
import { getUser } from '../db/users';
import type { LambdaEvent, LambdaResponse } from '../types';
import { noStoreJson, parseJsonObjectBody } from '../http';
import { json, noStoreJson, parseJsonObjectBody } from '../http';
import { headerValue } from '../httpHeaders';
import { recordRequestError } from '../requestErrorLog';

Expand Down Expand Up @@ -93,7 +93,3 @@ async function requireAdmin(
}
return { ok: true, actorId: user.id };
}

function json(statusCode: number, body: unknown): LambdaResponse {
return { statusCode, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
}
48 changes: 16 additions & 32 deletions backend/src/routes/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,12 @@ import {
type KnowledgeEmailTemplate,
type RenderedKnowledgeEmail,
} from '../sponsorDocs/knowledgeEmailTemplate';
import { noStoreJson, parseJsonObjectEventOrEmpty } from '../http';
import type { LambdaEvent, LambdaResponse } from '../types';

const SEND_PATH = '/api/email-templates/send';
const JSON_HEADERS = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function json(statusCode: number, body: unknown): LambdaResponse {
return { statusCode, headers: JSON_HEADERS, body: JSON.stringify(body) };
}

function parseBody(event: LambdaEvent): Record<string, unknown> | null {
if (!event.body) return {};
try {
const value = JSON.parse(typeof event.body === 'string' ? event.body : JSON.stringify(event.body));
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
} catch {
return null;
}
}

function emailTemplateValues(raw: unknown, recipientName: string): Record<string, string> | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const values: Record<string, string> = {};
Expand Down Expand Up @@ -102,50 +86,50 @@ export async function handleEmailTemplateRoutes(
client: DynamoDBDocumentClient,
): Promise<LambdaResponse | null> {
if (reqPath !== SEND_PATH) return null;
if (method.toUpperCase() !== 'POST') return json(405, { error: 'Method not allowed' });
if (method.toUpperCase() !== 'POST') return noStoreJson(405, { error: 'Method not allowed' });
const actor = await resolveInteractiveActor(client, event, 'work-write');
if (!actor.ok) return actor.response;

const body = parseBody(event);
if (!body) return json(400, { error: 'Invalid JSON body' });
const body = parseJsonObjectEventOrEmpty(event);
if (!body) return noStoreJson(400, { error: 'Invalid JSON body' });
const templateId = typeof body.templateId === 'string' ? body.templateId.trim() : '';
const sectionHeading = typeof body.sectionHeading === 'string' ? body.sectionHeading.trim() : '';
const recipientEmail = typeof body.recipientEmail === 'string' ? body.recipientEmail.trim().toLowerCase() : '';
const recipientName = typeof body.recipientName === 'string' ? body.recipientName.trim() : '';
const taskId = typeof body.taskId === 'string' ? body.taskId.trim() : '';
const subjectVariant = typeof body.subjectVariant === 'string' ? body.subjectVariant.trim() : undefined;

if (!/^[a-z0-9][a-z0-9._-]{1,127}$/.test(templateId)) return json(400, { error: 'A valid templateId is required' });
if (!/^[a-z0-9][a-z0-9._-]{1,127}$/.test(templateId)) return noStoreJson(400, { error: 'A valid templateId is required' });
if (!sectionHeading || sectionHeading.length > 200 || /[\r\n\x00-\x1f]/.test(sectionHeading)) {
return json(400, { error: 'A valid sectionHeading is required' });
return noStoreJson(400, { error: 'A valid sectionHeading is required' });
}
if (subjectVariant && (subjectVariant.length > 100 || /[\r\n\x00-\x1f]/.test(subjectVariant))) {
return json(400, { error: 'Invalid subjectVariant' });
return noStoreJson(400, { error: 'Invalid subjectVariant' });
}
if (!EMAIL.test(recipientEmail)) return json(400, { error: 'A valid recipientEmail is required' });
if (!EMAIL.test(recipientEmail)) return noStoreJson(400, { error: 'A valid recipientEmail is required' });
if (recipientEmail === 'grace@datatalks.club') {
return json(422, { error: 'Emails are never sent to grace@datatalks.club', code: 'blocked_recipient' });
return noStoreJson(422, { error: 'Emails are never sent to grace@datatalks.club', code: 'blocked_recipient' });
}
if (!taskId || taskId.length > 128) return json(400, { error: 'An owning taskId is required' });
if (!taskId || taskId.length > 128) return noStoreJson(400, { error: 'An owning taskId is required' });
const values = emailTemplateValues(body.values, recipientName);
if (!values) return json(400, { error: 'values must map uppercase placeholder names to strings; sender and recipient names are server-owned' });
if (!values) return noStoreJson(400, { error: 'values must map uppercase placeholder names to strings; sender and recipient names are server-owned' });

const task = await getTaskConsistent(client, taskId);
if (!task) return json(404, { error: `Task ${taskId} not found` });
if (!task) return noStoreJson(404, { error: `Task ${taskId} not found` });

let template: KnowledgeEmailTemplate;
try {
template = await loadKnowledgeEmailTemplate(templateId);
} catch (error) {
if (error instanceof KnowledgeEmailTemplateError) return json(404, { error: error.message, code: 'email_template_not_found' });
return json(503, { error: 'The private knowledge template is unavailable', code: 'email_template_unavailable' });
if (error instanceof KnowledgeEmailTemplateError) return noStoreJson(404, { error: error.message, code: 'email_template_not_found' });
return noStoreJson(503, { error: 'The private knowledge template is unavailable', code: 'email_template_unavailable' });
}

let draft: RenderedKnowledgeEmail;
try {
draft = renderKnowledgeEmailTemplate(template, { sectionHeading, subjectVariant, values });
} catch (error) {
if (error instanceof KnowledgeEmailTemplateError) return json(422, { error: error.message, code: 'email_template_render_failed' });
if (error instanceof KnowledgeEmailTemplateError) return noStoreJson(422, { error: error.message, code: 'email_template_render_failed' });
throw error;
}

Expand All @@ -162,7 +146,7 @@ export async function handleEmailTemplateRoutes(
...(emailDelivery.state === 'sent' ? { taskId } : {}),
};
if (delivery.state === 'sent') await recordEmailOnTask(client, taskId, template.id, delivery);
return json(200, {
return noStoreJson(200, {
emailDelivery: delivery,
sender: SPONSOR_DOC_EMAIL_FROM,
cc: [SPONSOR_DOC_EMAIL_CC],
Expand Down
Loading