diff --git a/.env.example b/.env.example index a0ae7f4..c31e2f3 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ RESEND_WEBHOOK_RECIPIENT_HASH_SECRET= MAP_NODE_EMAIL_FROM=Greenpill Network MAP_NODE_EMAIL_REPLY_TO=Greenpill Network MAP_NODE_EDIT_BASE_URL=https://greenpill.network/map/edit +# Comma-separated moderator recipients. Keep real addresses in Fly secrets. +MAP_NODE_MODERATION_RECIPIENTS= +MAP_NODE_MODERATION_DIRECTUS_URL=https://admin.greenpill.network # Public Garden newsletter subscribe form. Contacts are created globally in # Resend; segment/topic ids are optional and stay on the agent service. diff --git a/README.md b/README.md index d3b41be..df025a0 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ shared contracts instead of one-off route or website fetch logic. Public map-node submissions require an owner email. The agent stores that email only in `intake.map_node_private_contacts` so future owner updates can use one-use email magic links. Configure email sending on the agent with `RESEND_API_KEY`, `MAP_NODE_EMAIL_FROM`, `MAP_NODE_EMAIL_REPLY_TO`, and `MAP_NODE_EDIT_BASE_URL`; do not expose those values to the static website, Keystatic, generated JSON, or browser bundles. Map magic-link replies should route to the monitored map mailbox on the verified sending subdomain, currently `Greenpill Network `. Missing or failing email provider configuration still returns the same neutral public edit-link response. +When Live Onboarding Mode is off, every accepted map node is pending. Configure the private `MAP_NODE_MODERATION_RECIPIENTS` secret on `network-agent` (comma-separated operational addresses) to receive an immediate Resend alert and a 9:00 AM Pacific daily reminder while pending nodes remain. Each alert links to the exact authenticated Directus record at `MAP_NODE_MODERATION_DIRECTUS_URL`; approve or reject there so Directus records the actor and change history. Alerts contain only review-safe map fields, never the owner email, raw note, IP address, user agent, or spam metadata. The public form also enforces a server-side daily IP limit and a non-visible bot trap before either a pending record or an alert is created. + For steward onboarding sessions, an approved node is projected as a steward only when its private owner email matches an **active** Directus user with one chapter editor assignment. The agent resolves this privately on every map read and diff --git a/package.json b/package.json index 53a1055..006ea37 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "db:migrate": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/agent-db.migrate.ts", "db:cleanup:map-node-edit-flow": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-edit-flow-cleanup.ts", "db:deliver:map-node-edit-links": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-edit-link-delivery.ts", + "db:deliver:map-node-moderation": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-moderation-delivery.ts", "impact:sync": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/agent-impact-sync.ts", "content:snapshot": "bun run build:packages && bun --no-env-file scripts/operational-content.ts --write-snapshot", "content:migrate": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/operational-content.ts --migrate", @@ -38,6 +39,7 @@ "directus:studio:setup": "bun --env-file-if-exists=.env.local scripts/directus-studio-setup.ts", "directus:local:bootstrap": "bun --no-env-file scripts/wait-for-http.ts http://localhost:3302/server/ping 60000 && bun --no-env-file scripts/directus-operational-content-setup.ts && bun --no-env-file scripts/directus-studio-setup.ts", "directus:steward:smoke": "bun --env-file-if-exists=.env.local scripts/directus-steward-smoke.ts", + "directus:map-moderation:smoke": "bun --no-env-file scripts/directus-map-moderation-smoke.ts", "directus:users:invite": "bun --env-file-if-exists=.env.local scripts/directus-users.ts invite", "plans:scaffold": "bun --no-env-file scripts/plan-hub.ts scaffold", "plans:validate": "bun --no-env-file scripts/plan-hub.ts validate", @@ -46,7 +48,7 @@ "agentic:check": "bun run agentic:guidance && bun run check:browser-verification-policy && bun run plans:validate && bun run ui:check", "agentic:browser-proof": "bun scripts/require-authenticated-browser-qa.mjs && bun run ui:verify", "agentic:verify": "bun scripts/require-authenticated-browser-qa.mjs && bun run ui:verify", - "test:agent": "bun run build:packages && bun test scripts/agent-contract.test.ts", + "test:agent": "bun run build:packages && bun test scripts/agent-contract.test.ts && bun test scripts/map-node-moderation.test.ts", "test:chapter-impact": "bun run build:packages && bun test scripts/chapter-impact-contract.test.ts", "test:content": "bun run build:packages && bun test scripts/public-content-contract.test.ts", "test:map-edit": "bun run build:website && bun test scripts/map-edit-route.test.ts", diff --git a/packages/admin/README.md b/packages/admin/README.md index 223e13b..fe14599 100644 --- a/packages/admin/README.md +++ b/packages/admin/README.md @@ -260,6 +260,25 @@ the approved public projection available through `/map/state`. Turn it off manually after the session. Do not expose this toggle through public URLs, query parameters, browser storage, or generated website content. +In moderated mode, the agent sends configured operators a Resend alert with a +link to the exact `map_node_submissions` record in Directus. Sign in, review the +safe fields, and set `status` to `approved`, `rejected`, or `archived`; Directus +records the actor and status transition, while the database sets `approved_at` +on first approval. The `Pending map node approvals` bookmark is the fallback +queue when an email link has expired or been missed. + +The agent retries temporary alert failures and Resend deduplicates a retry of +the same alert. If retries are exhausted, the `Failed map moderation alerts` +bookmark exposes only the delivery status and provider error for moderators and +trusted publishers; it never exposes recipient addresses, owner email, raw +notes, IP addresses, or spam metadata. + +For a local end-to-end permission check, run +`bun run directus:map-moderation:smoke` while the local stack is running. It +creates and cleans up a temporary Steward Moderator, confirms that user can +approve a pending node, verifies `approved_at`, and checks Directus activity +records the moderator as the actor. + An approved node renders as a steward only when its private owner email matches an **active** Directus user with one chapter-editor assignment. The agent reads that relationship privately on every map projection and exposes only the public diff --git a/packages/agent/fly.toml b/packages/agent/fly.toml index fbd28b4..449c97f 100644 --- a/packages/agent/fly.toml +++ b/packages/agent/fly.toml @@ -12,6 +12,7 @@ primary_region = 'iad' [env] NODE_ENV = 'production' PORT = '8080' + MAP_NODE_MODERATION_DIRECTUS_URL = 'https://admin.greenpill.network' [http_service] internal_port = 8080 diff --git a/packages/agent/migrations/019_map_node_moderation_notifications.sql b/packages/agent/migrations/019_map_node_moderation_notifications.sql new file mode 100644 index 0000000..e2e6867 --- /dev/null +++ b/packages/agent/migrations/019_map_node_moderation_notifications.sql @@ -0,0 +1,85 @@ +-- Durable delivery queue for moderator alerts. Public map-node intake writes +-- directly through the agent, so Directus cannot be the notification source. + +create extension if not exists pgcrypto; + +create table if not exists intake.map_node_moderation_notifications ( + id uuid primary key default gen_random_uuid(), + kind text not null, + submission_id uuid references intake.map_node_submissions(id) on delete cascade, + digest_date date, + status text not null default 'queued', + attempts integer not null default 0, + next_attempt_at timestamptz not null default now(), + delivery_claimed_at timestamptz, + provider_message_id text, + provider_error text, + sent_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint map_node_moderation_notification_kind_check + check (kind in ('submission', 'daily_digest')), + constraint map_node_moderation_notification_status_check + check (status in ('queued', 'delivery_claimed', 'retry_scheduled', 'sent', 'failed', 'skipped')), + constraint map_node_moderation_notification_attempts_check + check (attempts >= 0), + constraint map_node_moderation_notification_shape_check + check ( + (kind = 'submission' and submission_id is not null and digest_date is null) + or + (kind = 'daily_digest' and submission_id is null and digest_date is not null) + ) +); + +create unique index if not exists map_node_moderation_notification_submission_idx + on intake.map_node_moderation_notifications (submission_id) + where kind = 'submission'; + +create unique index if not exists map_node_moderation_notification_digest_idx + on intake.map_node_moderation_notifications (digest_date) + where kind = 'daily_digest'; + +create index if not exists map_node_moderation_notification_delivery_queue_idx + on intake.map_node_moderation_notifications (status, next_attempt_at, created_at); + +create or replace function intake.touch_map_node_moderation_notification_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists map_node_moderation_notification_touch_updated_at + on intake.map_node_moderation_notifications; + +create trigger map_node_moderation_notification_touch_updated_at + before update on intake.map_node_moderation_notifications + for each row + execute function intake.touch_map_node_moderation_notification_updated_at(); + +create or replace function intake.apply_map_node_submission_moderation_transition() +returns trigger +language plpgsql +as $$ +begin + if new.status = 'approved'::intake.map_node_status + and old.status is distinct from 'approved'::intake.map_node_status + and new.approved_at is null then + new.approved_at = now(); + end if; + + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists map_node_submission_moderation_transition + on intake.map_node_submissions; + +create trigger map_node_submission_moderation_transition + before update on intake.map_node_submissions + for each row + execute function intake.apply_map_node_submission_moderation_transition(); diff --git a/packages/agent/src/map-node-moderation.ts b/packages/agent/src/map-node-moderation.ts new file mode 100644 index 0000000..ba973b3 --- /dev/null +++ b/packages/agent/src/map-node-moderation.ts @@ -0,0 +1,489 @@ +type SqlLike = any; +type FetchLike = typeof fetch; + +type ModerationNotificationKind = 'submission' | 'daily_digest'; + +interface ModerationNotificationRow { + id: string; + kind: ModerationNotificationKind; + attempts: number; + submissionId?: string; + displayName?: string; + placeName?: string; + city?: string; + region?: string; + country?: string; + lat?: number; + long?: number; + themes?: string[]; + publicNote?: string; + createdAt?: string | Date; +} + +export interface QueuedMapNodeModerationDeliveryResult { + queued: number; + delivered: number; + failed: number; + skipped: number; +} + +export const MAP_NODE_MODERATION_NOTIFICATION_STALE_CLAIM_MINUTES = 10; +export const MAP_NODE_MODERATION_NOTIFICATION_MAX_ATTEMPTS = 4; +export const MAP_NODE_MODERATION_NOTIFICATION_RETRY_MINUTES = Object.freeze([5, 30, 120]); +export const MAP_NODE_MODERATION_DIGEST_HOUR_PACIFIC = 9; + +const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails'; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const cleanString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); +const toSafeEmailText = (value: unknown, fallback = 'Not provided'): string => { + const cleaned = cleanString(value).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); + return (cleaned || fallback).slice(0, 500); +}; + +function parseRecipients(value: unknown): string[] { + return [...new Set( + cleanString(value) + .split(/[\s,;]+/) + .map((entry) => entry.toLowerCase()) + .filter((entry) => entry.length <= 320 && EMAIL_PATTERN.test(entry)) + )]; +} + +function buildDirectusUrl(baseUrl: string, path: string): string { + try { + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/$/, '')}${path}`; + url.search = ''; + url.hash = ''; + return url.href; + } catch { + return ''; + } +} + +function moderationConfig(env: Record = process.env) { + const directusBaseUrl = cleanString(env.MAP_NODE_MODERATION_DIRECTUS_URL); + return { + apiKey: cleanString(env.RESEND_API_KEY), + from: cleanString(env.MAP_NODE_EMAIL_FROM), + replyTo: cleanString(env.MAP_NODE_EMAIL_REPLY_TO), + recipients: parseRecipients(env.MAP_NODE_MODERATION_RECIPIENTS), + recordUrl: (id: string) => buildDirectusUrl( + directusBaseUrl, + `/admin/content/intake.map_node_submissions/${encodeURIComponent(id)}` + ), + queueUrl: buildDirectusUrl(directusBaseUrl, '/admin/content/intake.map_node_submissions'), + }; +} + +function canSendModerationEmail(env: Record = process.env): boolean { + const config = moderationConfig(env); + return Boolean(config.apiKey && config.from && config.recipients.length && config.queueUrl); +} + +function pacificDigestBucket(now: Date): { date: string; hour: number } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23', + }).formatToParts(now); + const value = (type: string) => parts.find((part) => part.type === type)?.value ?? ''; + return { + date: `${value('year')}-${value('month')}-${value('day')}`, + hour: Number(value('hour')), + }; +} + +function retryDelayMinutes(attempts: number): number | null { + if (attempts >= MAP_NODE_MODERATION_NOTIFICATION_MAX_ATTEMPTS) return null; + return MAP_NODE_MODERATION_NOTIFICATION_RETRY_MINUTES[attempts - 1] ?? null; +} + +function submissionEmailText(row: ModerationNotificationRow, recordUrl: string): string { + const place = [row.placeName, row.city, row.region, row.country] + .map((part) => toSafeEmailText(part, '')) + .filter(Boolean) + .join(', '); + const coordinates = Number.isFinite(Number(row.lat)) && Number.isFinite(Number(row.long)) + ? `${Number(row.lat)}, ${Number(row.long)}` + : 'Not provided'; + const themes = Array.isArray(row.themes) + ? row.themes.map((theme) => toSafeEmailText(theme, '')).filter(Boolean).join(', ') + : ''; + + return [ + 'A new Greenpill Network map node is awaiting approval.', + '', + `Name: ${toSafeEmailText(row.displayName)}`, + `Place: ${place || 'Not provided'}`, + `Coordinates: ${coordinates}`, + `Themes: ${themes || 'Not provided'}`, + `Public note: ${toSafeEmailText(row.publicNote)}`, + `Submitted: ${toSafeEmailText(row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt)}`, + '', + 'Review and approve or reject it in Directus:', + recordUrl, + '', + 'The submission owner email, raw notes, IP address, and spam metadata are intentionally not included here.', + ].join('\n'); +} + +function digestEmailText(pendingCount: number, queueUrl: string): string { + return [ + 'Greenpill Network map moderation reminder.', + '', + `${pendingCount} map node${pendingCount === 1 ? '' : 's'} await approval.`, + '', + 'Open the pending map-node queue in Directus:', + queueUrl, + ].join('\n'); +} + +async function sendModerationEmail({ + row, + pendingCount, + env = process.env, + fetchImpl = globalThis.fetch, +}: { + row: ModerationNotificationRow; + pendingCount: number; + env?: Record; + fetchImpl?: FetchLike; +}): Promise<{ status: 'sent' | 'send_failed'; error: string; providerMessageId: string }> { + const config = moderationConfig(env); + const isSubmission = row.kind === 'submission'; + const submissionId = cleanString(row.submissionId); + const recordUrl = isSubmission && submissionId ? config.recordUrl(submissionId) : ''; + if ( + !config.apiKey || + !config.from || + !config.recipients.length || + !config.queueUrl || + (isSubmission && !recordUrl) || + typeof fetchImpl !== 'function' + ) { + return { status: 'send_failed', error: 'provider_not_configured', providerMessageId: '' }; + } + + const subject = row.kind === 'submission' + ? 'Greenpill map node awaiting approval' + : 'Greenpill map moderation reminder'; + const text = row.kind === 'submission' + ? submissionEmailText(row, recordUrl) + : digestEmailText(pendingCount, config.queueUrl); + + try { + const response = await fetchImpl(RESEND_EMAILS_ENDPOINT, { + method: 'POST', + headers: { + authorization: `Bearer ${config.apiKey}`, + 'content-type': 'application/json', + 'Idempotency-Key': `map-node-moderation-${row.id}`, + }, + body: JSON.stringify({ + from: config.from, + to: config.recipients, + subject, + text, + ...(config.replyTo ? { reply_to: config.replyTo } : {}), + }), + }); + if (!response.ok) { + return { status: 'send_failed', error: `resend_http_${response.status}`, providerMessageId: '' }; + } + let providerMessageId = ''; + try { + const payload = await response.json(); + providerMessageId = cleanString(payload?.id); + } catch { + // Sending succeeded even if the provider response body was unavailable. + } + return { status: 'sent', error: '', providerMessageId }; + } catch { + return { status: 'send_failed', error: 'resend_fetch_failed', providerMessageId: '' }; + } +} + +export async function queueMapNodeModerationNotification( + sql: SqlLike, + submissionId: string +): Promise { + const rows = await sql` + insert into intake.map_node_moderation_notifications ( + kind, + submission_id + ) + values ( + 'submission', + ${submissionId}::uuid + ) + on conflict do nothing + returning id::text + `; + return Boolean(rows[0]?.id); +} + +async function retireResolvedSubmissionNotifications(sql: SqlLike): Promise { + await sql` + update intake.map_node_moderation_notifications notification + set + status = 'skipped', + delivery_claimed_at = null + where notification.kind = 'submission' + and notification.status in ('queued', 'retry_scheduled', 'delivery_claimed') + and not exists ( + select 1 + from intake.map_node_submissions submission + where submission.id = notification.submission_id + and submission.status = 'pending'::intake.map_node_status + ) + `; +} + +async function queueDailyDigestIfDue(sql: SqlLike, now: Date): Promise { + const bucket = pacificDigestBucket(now); + if (!Number.isFinite(bucket.hour) || bucket.hour < MAP_NODE_MODERATION_DIGEST_HOUR_PACIFIC) return; + + const [pending] = await sql` + select count(*)::int as count + from intake.map_node_submissions + where status = 'pending'::intake.map_node_status + `; + if ((pending?.count ?? 0) <= 0) return; + + await sql` + insert into intake.map_node_moderation_notifications ( + kind, + digest_date + ) + values ( + 'daily_digest', + ${bucket.date}::date + ) + on conflict do nothing + `; +} + +async function claimModerationNotification( + sql: SqlLike, + notificationId: string +): Promise<{ attempts: number } | null> { + const rows = await sql` + update intake.map_node_moderation_notifications notification + set + status = 'delivery_claimed', + attempts = notification.attempts + 1, + delivery_claimed_at = now(), + provider_error = null + where notification.id = ${notificationId}::uuid + and ( + notification.status in ('queued', 'retry_scheduled') + or ( + notification.status = 'delivery_claimed' + and notification.delivery_claimed_at < now() - ${MAP_NODE_MODERATION_NOTIFICATION_STALE_CLAIM_MINUTES} * interval '1 minute' + ) + ) + and ( + notification.kind = 'daily_digest' + or exists ( + select 1 + from intake.map_node_submissions submission + where submission.id = notification.submission_id + and submission.status = 'pending'::intake.map_node_status + ) + ) + returning attempts + `; + return rows[0] ?? null; +} + +async function pendingSubmissionCount(sql: SqlLike): Promise { + const [pending] = await sql` + select count(*)::int as count + from intake.map_node_submissions + where status = 'pending'::intake.map_node_status + `; + return Number(pending?.count ?? 0); +} + +async function completeModerationNotification({ + sql, + notificationId, + attempts, + result, +}: { + sql: SqlLike; + notificationId: string; + attempts: number; + result: { status: 'sent' | 'send_failed'; error: string; providerMessageId: string }; +}): Promise<'sent' | 'failed'> { + if (result.status === 'sent') { + await sql` + update intake.map_node_moderation_notifications + set + status = 'sent', + sent_at = now(), + delivery_claimed_at = null, + provider_error = null, + provider_message_id = ${result.providerMessageId || null} + where id = ${notificationId}::uuid + `; + return 'sent'; + } + + const retryDelay = retryDelayMinutes(attempts); + if (retryDelay !== null) { + await sql` + update intake.map_node_moderation_notifications + set + status = 'retry_scheduled', + next_attempt_at = now() + ${retryDelay} * interval '1 minute', + delivery_claimed_at = null, + provider_error = ${result.error || 'send_failed'} + where id = ${notificationId}::uuid + `; + } else { + await sql` + update intake.map_node_moderation_notifications + set + status = 'failed', + delivery_claimed_at = null, + provider_error = ${result.error || 'send_failed'} + where id = ${notificationId}::uuid + `; + } + return 'failed'; +} + +export async function deliverQueuedMapNodeModerationNotifications( + sql: SqlLike, + { + limit = 20, + env = process.env, + fetchImpl = globalThis.fetch, + now = new Date(), + }: { + limit?: number; + env?: Record; + fetchImpl?: FetchLike; + now?: Date; + } = {} +): Promise { + if (!canSendModerationEmail(env)) { + return { queued: 0, delivered: 0, failed: 0, skipped: 0 }; + } + + await retireResolvedSubmissionNotifications(sql); + await queueDailyDigestIfDue(sql, now); + + const numericLimit = Number(limit); + const cappedLimit = Math.min(100, Math.max(1, Number.isFinite(numericLimit) ? Math.trunc(numericLimit) : 20)); + const rows = await sql` + select + notification.id::text, + notification.kind, + notification.attempts, + notification.submission_id::text as "submissionId", + submission.display_name as "displayName", + submission.place_name as "placeName", + submission.city, + submission.region, + submission.country, + submission.latitude::float8 as lat, + submission.longitude::float8 as long, + submission.themes, + submission.public_note as "publicNote", + submission.created_at as "createdAt" + from intake.map_node_moderation_notifications notification + left join intake.map_node_submissions submission on submission.id = notification.submission_id + where ( + ( + notification.status in ('queued', 'retry_scheduled') + and notification.next_attempt_at <= now() + ) + or ( + notification.status = 'delivery_claimed' + and notification.delivery_claimed_at < now() - ${MAP_NODE_MODERATION_NOTIFICATION_STALE_CLAIM_MINUTES} * interval '1 minute' + ) + ) + and ( + notification.kind = 'daily_digest' + or submission.status = 'pending'::intake.map_node_status + ) + order by notification.created_at asc + limit ${cappedLimit} + `; + + const delivery = { + queued: rows.length, + delivered: 0, + failed: 0, + skipped: 0, + }; + + for (const row of rows as ModerationNotificationRow[]) { + const claim = await claimModerationNotification(sql, cleanString(row.id)); + if (!claim) { + delivery.skipped += 1; + continue; + } + + const pendingCount = row.kind === 'daily_digest' ? await pendingSubmissionCount(sql) : 0; + if (row.kind === 'daily_digest' && pendingCount === 0) { + await sql` + update intake.map_node_moderation_notifications + set status = 'skipped', delivery_claimed_at = null + where id = ${row.id}::uuid + `; + delivery.skipped += 1; + continue; + } + + const result = await sendModerationEmail({ row, pendingCount, env, fetchImpl }); + const status = await completeModerationNotification({ + sql, + notificationId: cleanString(row.id), + attempts: Number(claim.attempts ?? row.attempts ?? 0), + result, + }); + if (status === 'sent') delivery.delivered += 1; + else delivery.failed += 1; + } + + return delivery; +} + +export function scheduleMapNodeModerationNotificationDelivery({ + sql, + env, + fetchImpl, + createSqlForDeferredDelivery, +}: { + sql: SqlLike; + env: Record; + fetchImpl: FetchLike; + createSqlForDeferredDelivery?: (options?: { max?: number }) => SqlLike | null; +}): void { + if (!canSendModerationEmail(env)) return; + + setTimeout(() => { + void (async () => { + const deliverySql = createSqlForDeferredDelivery?.({ max: 1 }) ?? sql; + if (!deliverySql) return; + const shouldEnd = deliverySql !== sql; + try { + await deliverQueuedMapNodeModerationNotifications(deliverySql, { env, fetchImpl }); + } catch (error) { + console.warn('map_node_moderation_notification_delivery_failed', { + errorName: error instanceof Error ? error.name : 'UnknownError', + }); + } finally { + if (shouldEnd) await deliverySql.end({ timeout: 3 }).catch(() => {}); + } + })(); + }, 0); +} diff --git a/packages/agent/src/map-nodes.ts b/packages/agent/src/map-nodes.ts index 9cb23eb..3ec9b8a 100644 --- a/packages/agent/src/map-nodes.ts +++ b/packages/agent/src/map-nodes.ts @@ -15,6 +15,11 @@ import type { } from '@greenpill-network/shared/map-nodes'; import type { PublicMapIntakeMode } from '@greenpill-network/shared/map-state'; import { createDatabaseClient } from './db.js'; +import { + deliverQueuedMapNodeModerationNotifications, + queueMapNodeModerationNotification, + scheduleMapNodeModerationNotificationDelivery, +} from './map-node-moderation.js'; type SqlLike = any; type UnknownRecord = Record; @@ -51,6 +56,7 @@ export interface PublicMapNodeSubmissionInput extends UnknownRecord { private_email?: string; contactConsent?: boolean; contact_consent?: boolean; + website?: string; } export interface PublicMapNodeUpdateRequestInput extends UnknownRecord { @@ -85,6 +91,13 @@ type EditLinkRequestOptions = { createSqlForDeferredDelivery?: (options?: { max?: number }) => SqlLike | null; }; +type MapNodeSubmissionOptions = { + env?: Record; + fetchImpl?: FetchLike; + deferProviderSend?: boolean; + createSqlForDeferredDelivery?: (options?: { max?: number }) => SqlLike | null; +}; + export interface QueuedMapNodeEditLinkDeliveryResult { queued: number; delivered: number; @@ -107,6 +120,7 @@ export const MAP_NODE_EDIT_LINK_COOLDOWN_MINUTES = 15; export const MAP_NODE_EDIT_LINK_DAILY_IP_LIMIT = 30; export const MAP_NODE_EDIT_LINK_DAILY_EMAIL_LIMIT = 10; export const MAP_NODE_EDIT_LINK_STALE_DELIVERY_CLAIM_MINUTES = 10; +export const MAP_NODE_SUBMISSION_DAILY_IP_LIMIT = 5; export const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails'; export const MAP_NODE_THEME_MIN_COUNT = 1; export const MAP_NODE_THEME_MAX_COUNT = 4; @@ -281,6 +295,9 @@ export async function getMapNodeIntakeMode(sql: SqlLike): Promise { + const normalizedRateLimitKey = cleanString(rateLimitKey) || 'anonymous'; + await tx`select pg_advisory_xact_lock(hashtext(${normalizedRateLimitKey}))`; + const [bucket] = await tx` + select count(*)::int as count + from intake.map_node_submissions + where rate_limit_key = ${normalizedRateLimitKey} + and created_at >= now() - interval '24 hours' + `; + if ((bucket?.count ?? 0) >= MAP_NODE_SUBMISSION_DAILY_IP_LIMIT) { + throw new PublicInputError( + 'rate_limited', + 'Too many map-node submissions came from this network. Please try again tomorrow.', + 429 + ); + } +} + export async function createMapNodeSubmission( sql: SqlLike, input: PublicMapNodeSubmissionInput, - requestMeta: RequestMeta = {} + requestMeta: RequestMeta = {}, + { + env = process.env, + fetchImpl = globalThis.fetch, + deferProviderSend = false, + createSqlForDeferredDelivery, + }: MapNodeSubmissionOptions = {} ): Promise { const normalized = normalizeSubmissionInput(input); const meta = { @@ -354,9 +395,12 @@ export async function createMapNodeSubmission( rateLimitKey: cleanString(requestMeta.rateLimitKey), }; - return sql.begin(async (tx) => { + const result = await sql.begin(async (tx) => { const intakeMode = await getMapNodeIntakeMode(tx); const liveOnboarding = intakeMode === 'live'; + if (!liveOnboarding) { + await assertMapNodeSubmissionRateLimit(tx, meta.rateLimitKey); + } const submissionStatus = liveOnboarding ? 'approved' : 'pending'; const approvedAt = liveOnboarding ? new Date() : null; const [submission] = await tx` @@ -434,6 +478,7 @@ export async function createMapNodeSubmission( `; } + let moderationNotificationQueued = false; if (liveOnboarding) { await tx` insert into intake.map_node_reviews ( @@ -449,6 +494,8 @@ export async function createMapNodeSubmission( 'Auto-approved while live onboarding mode was enabled.' ) `; + } else { + moderationNotificationQueued = await queueMapNodeModerationNotification(tx, submission.id); } const steward = liveOnboarding @@ -476,8 +523,19 @@ export async function createMapNodeSubmission( ); } - return publicNode; + return { node: publicNode, moderationNotificationQueued }; }); + + if (result.moderationNotificationQueued && !deferProviderSend) { + scheduleMapNodeModerationNotificationDelivery({ + sql, + env, + fetchImpl, + createSqlForDeferredDelivery, + }); + } + + return result.node; } function normalizeUpdateText(input: UnknownRecord, keys: string[]): string | undefined { @@ -1579,7 +1637,11 @@ export function createMapNodeRepository({ } = {}) { return { createSubmission(input, requestMeta) { - return withSql(createSql, (sql) => createMapNodeSubmission(sql, input, requestMeta)); + return withSql(createSql, (sql) => createMapNodeSubmission(sql, input, requestMeta, { + env, + fetchImpl, + createSqlForDeferredDelivery: createSql, + })); }, requestEditLink(nodeId, email, requestMeta) { return withSql(createSql, (sql) => createMapNodeEditLinkRequest(sql, nodeId, email, requestMeta, { @@ -1611,6 +1673,13 @@ export function createMapNodeRepository({ ...options, })); }, + deliverQueuedModerationNotifications(options?: { limit?: number; now?: Date }) { + return withSql(createSql, (sql) => deliverQueuedMapNodeModerationNotifications(sql, { + env, + fetchImpl, + ...options, + })); + }, listPublic() { return withSql(createSql, listPublicMapNodes); }, diff --git a/packages/agent/src/server.ts b/packages/agent/src/server.ts index 811f8e0..467d563 100644 --- a/packages/agent/src/server.ts +++ b/packages/agent/src/server.ts @@ -18,26 +18,41 @@ const editLinkDeliverySweepIntervalMs = parsePositiveInteger( process.env.MAP_NODE_EDIT_LINK_DELIVERY_SWEEP_INTERVAL_MS, 5 * 60 * 1000 ); +const moderationDeliverySweepIntervalMs = parsePositiveInteger( + process.env.MAP_NODE_MODERATION_DELIVERY_SWEEP_INTERVAL_MS, + editLinkDeliverySweepIntervalMs +); export function startMapNodeEditLinkDeliverySweep({ env = process.env, repository = createMapNodeRepository({ env }), }: { env?: Record; - repository?: { deliverQueuedEditLinks?: (options?: { limit?: number }) => Promise }; + repository?: { + deliverQueuedEditLinks?: (options?: { limit?: number }) => Promise; + deliverQueuedModerationNotifications?: (options?: { limit?: number }) => Promise; + }; } = {}): (() => void) | null { - if (!env.DATABASE_URL || env.MAP_NODE_EDIT_LINK_DELIVERY_SWEEP_ENABLED === 'false') return null; - if (typeof repository.deliverQueuedEditLinks !== 'function') return null; - const deliverQueuedEditLinks = repository.deliverQueuedEditLinks.bind(repository); + if (!env.DATABASE_URL) return null; + const deliverQueuedEditLinks = env.MAP_NODE_EDIT_LINK_DELIVERY_SWEEP_ENABLED === 'false' + ? null + : repository.deliverQueuedEditLinks?.bind(repository); + const deliverQueuedModerationNotifications = env.MAP_NODE_MODERATION_DELIVERY_SWEEP_ENABLED === 'false' + ? null + : repository.deliverQueuedModerationNotifications?.bind(repository); + if (!deliverQueuedEditLinks && !deliverQueuedModerationNotifications) return null; let running = false; const run = async () => { if (running) return; running = true; try { - await deliverQueuedEditLinks({ limit: 20 }); + if (deliverQueuedEditLinks) await deliverQueuedEditLinks({ limit: 20 }); + if (deliverQueuedModerationNotifications) { + await deliverQueuedModerationNotifications({ limit: 20 }); + } } catch (error) { - console.warn('map_node_edit_link_delivery_sweep_failed', { + console.warn('map_node_delivery_sweep_failed', { errorName: error instanceof Error ? error.name : 'UnknownError', }); } finally { @@ -50,7 +65,7 @@ export function startMapNodeEditLinkDeliverySweep({ }, 1000); const interval = setInterval(() => { void run(); - }, editLinkDeliverySweepIntervalMs); + }, Math.min(editLinkDeliverySweepIntervalMs, moderationDeliverySweepIntervalMs)); bootTimer.unref?.(); interval.unref?.(); diff --git a/packages/website/src/components/page-sections/HomeMap.astro b/packages/website/src/components/page-sections/HomeMap.astro index 933573c..a7e666c 100644 --- a/packages/website/src/components/page-sections/HomeMap.astro +++ b/packages/website/src/components/page-sections/HomeMap.astro @@ -409,6 +409,12 @@ const statsA11y = `World map of ${chapterCount} Greenpill chapters. Approved ste

What are you here to grow?

+

What are you here to grow?

Choose 1 to 4 themes to continue. These choose the color of your threads on the map and find your people.

@@ -3804,6 +3810,7 @@ const statsA11y = `World map of ${chapterCount} Greenpill chapters. Approved ste const publicNote = clean(data.get('publicNote')); const emailInput = addForm.elements.namedItem('contact') as HTMLInputElement | null; const email = clean(data.get('contact')).toLowerCase(); + const website = clean(data.get('website')); if (emailInput) emailInput.value = email; if (!name || !publicNote || !place || lat === null || long === null || !emailInput?.checkValidity()) { @@ -3833,6 +3840,7 @@ const statsA11y = `World map of ${chapterCount} Greenpill chapters. Approved ste publicNote, email, contactConsent: true, + website, }), }); const payload = await response.json().catch(() => ({})); diff --git a/scripts/agent-contract.test.ts b/scripts/agent-contract.test.ts index 1c6abbc..173e58e 100644 --- a/scripts/agent-contract.test.ts +++ b/scripts/agent-contract.test.ts @@ -157,6 +157,8 @@ test('agent server starts a durable edit-link delivery sweep without logging pri assert.match(source, /DATABASE_URL/); assert.match(source, /MAP_NODE_EDIT_LINK_DELIVERY_SWEEP_ENABLED/); assert.match(source, /deliverQueuedEditLinks/); + assert.match(source, /MAP_NODE_MODERATION_DELIVERY_SWEEP_ENABLED/); + assert.match(source, /deliverQueuedModerationNotifications/); assert.match(source, /errorName/); assert.doesNotMatch(source, /console\.warn\([^;]*(email|token|normalized_email|request_ip)/s); }); @@ -1039,7 +1041,7 @@ test('edit-session and update-request routes expose generic token semantics', as assert.deepEqual(await invalidUpdate.json(), MAP_NODE_INVALID_EDIT_LINK_ERROR); }); -function createFakeSubmissionSql({ liveOnboardingEnabled, stewardChapterSlug = '' }) { +function createFakeSubmissionSql({ liveOnboardingEnabled, stewardChapterSlug = '', submissionCount = 0 }) { const statements = []; const tx = async (strings, ...values) => { const text = strings.join('?').replace(/\s+/g, ' ').trim(); @@ -1049,6 +1051,10 @@ function createFakeSubmissionSql({ liveOnboardingEnabled, stewardChapterSlug = ' return [{ liveOnboardingEnabled }]; } + if (text.includes('select count(*)::int as count from intake.map_node_submissions')) { + return [{ count: submissionCount }]; + } + if (text.includes('from public.directus_users')) { return stewardChapterSlug ? [{ chapterSlug: stewardChapterSlug }] : []; } @@ -1072,6 +1078,10 @@ function createFakeSubmissionSql({ liveOnboardingEnabled, stewardChapterSlug = ' }]; } + if (text.includes('insert into intake.map_node_moderation_notifications')) { + return [{ id: 'moderation-notification-1' }]; + } + return []; }; @@ -1097,6 +1107,66 @@ test('map-node submissions stay pending unless live onboarding is enabled', asyn statements.some((statement) => statement.text.includes('insert into intake.map_node_reviews')), false ); + assert.equal( + statements.some((statement) => statement.text.includes('insert into intake.map_node_moderation_notifications')), + true + ); +}); + +test('live map-node onboarding does not apply the moderated intake rate limit', async () => { + const { sql, statements } = createFakeSubmissionSql({ + liveOnboardingEnabled: true, + submissionCount: 5, + }); + const node = await createMapNodeSubmission(sql, { + name: 'Live session member', + place: 'Oakland', + lat: 37.8044, + long: -122.2712, + themes: ['public'], + email: 'member@example.com', + }, { rateLimitKey: '203.0.113.8' }); + + assert.equal(node.status, 'approved'); + assert.equal( + statements.some((statement) => statement.text.includes('select count(*)::int as count from intake.map_node_submissions')), + false + ); +}); + +test('map-node honeypot and daily IP limit reject before storing a submission', async () => { + const honeypot = createFakeSubmissionSql({ liveOnboardingEnabled: false }); + await assert.rejects( + () => createMapNodeSubmission(honeypot.sql, { + name: 'Bot', + place: 'Oakland', + lat: 37.8044, + long: -122.2712, + themes: ['public'], + email: 'private@example.com', + website: 'https://spam.example', + }), + (error) => error instanceof PublicInputError && error.code === 'spam_detected' + ); + assert.equal(honeypot.statements.some((statement) => ( + statement.text.includes('insert into intake.map_node_submissions') + )), false); + + const limited = createFakeSubmissionSql({ liveOnboardingEnabled: false, submissionCount: 5 }); + await assert.rejects( + () => createMapNodeSubmission(limited.sql, { + name: 'Limited Member', + place: 'Oakland', + lat: 37.8044, + long: -122.2712, + themes: ['public'], + email: 'private@example.com', + }, { rateLimitKey: '203.0.113.8' }), + (error) => error instanceof PublicInputError && error.code === 'rate_limited' && error.status === 429 + ); + assert.equal(limited.statements.some((statement) => ( + statement.text.includes('insert into intake.map_node_submissions') + )), false); }); test('map-node submissions require one to four themes', async () => { diff --git a/scripts/directus-map-moderation-smoke.ts b/scripts/directus-map-moderation-smoke.ts new file mode 100644 index 0000000..9b430c6 --- /dev/null +++ b/scripts/directus-map-moderation-smoke.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env bun + +import { randomUUID } from 'node:crypto'; +import { createDirectusClient } from './directus-operational-content-setup.ts'; + +function tokenValue() { + return `${randomUUID().replaceAll('-', '')}${randomUUID().replaceAll('-', '')}`; +} + +async function getRoleId(client, name: string) { + const query = new URLSearchParams({ + 'filter[name][_eq]': name, + limit: '1', + }); + const response = await client.request(`/roles?${query}`); + const role = response?.data?.[0]; + if (!role?.id) throw new Error(`Directus role not found: ${name}`); + return role.id; +} + +async function expectForbidden(label: string, action: () => Promise) { + try { + await action(); + } catch (error) { + if (String(error).includes(' failed with 403:')) return; + throw error; + } + throw new Error(`${label} unexpectedly succeeded.`); +} + +const admin = await createDirectusClient(); +const id = randomUUID().slice(0, 8); +const moderatorRoleId = await getRoleId(admin, 'Greenpill Steward Moderator'); +const moderatorEmail = `map-moderation-smoke-${id}@greenpill.network`; +const moderatorToken = tokenValue(); +let moderatorId = ''; +let submissionId = ''; + +try { + const moderator = await admin.request('/users', { + method: 'POST', + body: { + email: moderatorEmail, + first_name: 'Map Moderation', + last_name: 'Smoke', + status: 'active', + role: moderatorRoleId, + password: tokenValue(), + token: moderatorToken, + }, + }); + moderatorId = moderator?.data?.id ?? ''; + if (!moderatorId) throw new Error('Directus did not return a map moderation smoke user id.'); + + const submission = await admin.request('/items/map_node_submissions', { + method: 'POST', + body: { + status: 'pending', + display_name: `Map moderation smoke ${id}`, + place_name: 'Oakland', + latitude: 37.8044, + longitude: -122.2712, + public_note: 'Temporary Directus moderation smoke record.', + }, + }); + submissionId = submission?.data?.id ?? ''; + if (!submissionId) throw new Error('Directus did not return a map moderation smoke submission id.'); + + const steward = await createDirectusClient({ url: admin.url, token: moderatorToken }); + const pending = await steward.request(`/items/map_node_submissions/${encodeURIComponent(submissionId)}`); + if (pending?.data?.status !== 'pending') { + throw new Error('Steward Moderator could not read the pending map-node submission.'); + } + + const approved = await steward.request(`/items/map_node_submissions/${encodeURIComponent(submissionId)}`, { + method: 'PATCH', + body: { status: 'approved' }, + }); + if (approved?.data?.status !== 'approved' || !approved?.data?.approved_at) { + throw new Error('Steward Moderator approval did not set the approved status and timestamp.'); + } + + const notificationQueue = await steward.request( + '/items/map_node_moderation_notifications?fields=id,status,provider_error,submission_id&limit=1' + ); + if (!Array.isArray(notificationQueue?.data)) { + throw new Error('Steward Moderator could not read the safe moderation delivery queue.'); + } + + await expectForbidden('private contact read', () => steward.request('/items/map_node_private_contacts?limit=1')); + + const activityQuery = new URLSearchParams({ + 'filter[collection][_eq]': 'map_node_submissions', + 'filter[item][_eq]': submissionId, + 'filter[action][_eq]': 'update', + fields: 'id,user,action,item', + limit: '5', + }); + const activity = await admin.request(`/activity?${activityQuery}`); + const approvalActivity = (activity?.data ?? []).find((entry) => entry.user === moderatorId); + if (!approvalActivity) { + throw new Error('Directus did not record the moderator as the map-node approval actor.'); + } + + console.log(`Directus map moderation smoke passed at ${admin.url}`); + console.log(`Approved temporary submission: ${submissionId}`); + console.log(`Recorded moderator actor: ${moderatorId}`); +} finally { + if (submissionId) { + await admin.request(`/items/map_node_submissions/${encodeURIComponent(submissionId)}`, { + method: 'DELETE', + expected: [204, 404], + }); + } + if (moderatorId) { + await admin.request(`/users/${encodeURIComponent(moderatorId)}`, { + method: 'DELETE', + expected: [204, 404], + }); + } +} diff --git a/scripts/directus-operational-content-setup.ts b/scripts/directus-operational-content-setup.ts index 2f84c30..dc7906a 100644 --- a/scripts/directus-operational-content-setup.ts +++ b/scripts/directus-operational-content-setup.ts @@ -15,6 +15,7 @@ export const DIRECTUS_INTAKE_COLLECTIONS = Object.freeze([ 'map_node_submissions', 'map_node_private_contacts', 'map_node_reviews', + 'map_node_moderation_notifications', 'map_node_intake_settings', 'map_node_edit_tokens', 'map_node_update_requests', @@ -255,6 +256,22 @@ const MAP_NODE_REVIEW_WRITE_FIELDS = Object.freeze([ 'review_notes', ]); +const MAP_NODE_MODERATION_NOTIFICATION_READ_FIELDS = Object.freeze([ + 'id', + 'kind', + 'submission_id', + 'digest_date', + 'status', + 'attempts', + 'next_attempt_at', + 'delivery_claimed_at', + 'provider_message_id', + 'provider_error', + 'sent_at', + 'created_at', + 'updated_at', +]); + const MAP_NODE_PRIVATE_CONTACT_READ_FIELDS = Object.freeze([ 'submission_id', 'email', @@ -633,6 +650,7 @@ function buildIntakeModerationPermissions(collectionNames = []) { const submission = collections.get('map_node_submissions'); const privateContacts = collections.get('map_node_private_contacts'); const reviews = collections.get('map_node_reviews'); + const moderationNotifications = collections.get('map_node_moderation_notifications'); const settings = collections.get('map_node_intake_settings'); const editTokens = collections.get('map_node_edit_tokens'); const updateRequests = collections.get('map_node_update_requests'); @@ -731,6 +749,31 @@ function buildIntakeModerationPermissions(collectionNames = []) { ); } + if (moderationNotifications) { + permissions.push( + { + role: 'Greenpill Steward Moderator', + policy: 'Greenpill Steward Moderator', + collection: moderationNotifications, + action: 'read', + permissions: null, + validation: null, + presets: null, + fields: MAP_NODE_MODERATION_NOTIFICATION_READ_FIELDS, + }, + { + role: 'Greenpill Trusted Publisher', + policy: 'Greenpill Trusted Publisher', + collection: moderationNotifications, + action: 'read', + permissions: null, + validation: null, + presets: null, + fields: MAP_NODE_MODERATION_NOTIFICATION_READ_FIELDS, + } + ); + } + if (privateContacts) { permissions.push({ role: 'Greenpill Trusted Publisher', diff --git a/scripts/directus-studio-setup.test.ts b/scripts/directus-studio-setup.test.ts index 251fbbf..bd1ac63 100644 --- a/scripts/directus-studio-setup.test.ts +++ b/scripts/directus-studio-setup.test.ts @@ -10,7 +10,8 @@ test('Directus Studio metadata plan hides assignment collections and labels cont 'chapter_update_requests', 'chapter_update_request_links', 'chapter_update_request_proof_signals', - ] + ], + ['map_node_submissions', 'map_node_moderation_notifications'] ); const chapters = plan.collections.find((collection) => collection.collection === 'chapters'); @@ -20,6 +21,12 @@ test('Directus Studio metadata plan hides assignment collections and labels cont const updateRequestProofSignals = plan.collections.find((collection) => ( collection.collection === 'chapter_update_request_proof_signals' )); + const mapNodeSubmissions = plan.collections.find((collection) => ( + collection.collection === 'map_node_submissions' + )); + const mapNodeNotifications = plan.collections.find((collection) => ( + collection.collection === 'map_node_moderation_notifications' + )); const chapterSummary = plan.fields.find((field) => field.collection === 'chapters' && field.field === 'summary'); const initiativeChapter = plan.fields.find((field) => ( field.collection === 'chapter_initiatives' && @@ -59,6 +66,12 @@ test('Directus Studio metadata plan hides assignment collections and labels cont )); const projectGuild = plan.fields.find((field) => field.collection === 'projects' && field.field === 'guild_slug'); const rawData = plan.fields.find((field) => field.collection === 'chapters' && field.field === 'data'); + const mapNodeStatus = plan.fields.find((field) => ( + field.collection === 'map_node_submissions' && field.field === 'status' + )); + const mapNotificationStatus = plan.fields.find((field) => ( + field.collection === 'map_node_moderation_notifications' && field.field === 'status' + )); assert.equal(chapters?.meta.icon, 'location_city'); assert.equal(chapters?.meta.display_template, '{{ name }}'); @@ -70,6 +83,8 @@ test('Directus Studio metadata plan hides assignment collections and labels cont assert.equal(updateRequests?.meta.preview_url, 'https://greenpill.network/chapters/{{ chapter_slug }}'); assert.equal(updateRequestLinks?.meta.hidden, true); assert.equal(updateRequestProofSignals?.meta.hidden, true); + assert.equal(mapNodeSubmissions?.meta.icon, 'approval'); + assert.equal(mapNodeNotifications?.meta.icon, 'mark_email_unread'); assert.equal((chapterSummary?.meta as any).interface, 'input-multiline'); assert.equal((initiativeChapter?.meta as any).interface, 'select-dropdown-m2o'); assert.deepEqual((initiativeChapter?.meta as any).special, ['m2o']); @@ -85,6 +100,9 @@ test('Directus Studio metadata plan hides assignment collections and labels cont assert.equal((projectGuild?.meta as any).interface, 'select-dropdown-m2o'); assert.equal((rawData?.meta as any).hidden, true); assert.equal((rawData?.meta as any).readonly, true); + assert.equal((mapNodeStatus?.meta as any).interface, 'select-dropdown'); + assert.equal((mapNodeStatus?.meta as any).required, true); + assert.equal((mapNotificationStatus?.meta as any).readonly, true); }); test('Directus Studio bookmark plan adds steward and publisher working views', () => { @@ -92,10 +110,14 @@ test('Directus Studio bookmark plan adds steward and publisher working views', ( 'chapters', 'chapter_initiatives', 'chapter_update_requests', + 'map_node_submissions', + 'map_node_moderation_notifications', ]); const stewardRequests = bookmarks.find((bookmark) => bookmark.bookmark === 'My chapter change requests'); const publisherReviews = bookmarks.find((bookmark) => bookmark.bookmark === 'Pending chapter reviews'); + const mapApprovals = bookmarks.filter((bookmark) => bookmark.bookmark === 'Pending map node approvals'); + const failedAlerts = bookmarks.filter((bookmark) => bookmark.bookmark === 'Failed map moderation alerts'); assert.equal(stewardRequests?.role, 'Greenpill Steward Editor'); assert.equal(stewardRequests?.collection, 'chapter_update_requests'); @@ -110,4 +132,13 @@ test('Directus Studio bookmark plan adds steward and publisher working views', ( _eq: 'pending_review', }, }); + assert.equal(mapApprovals.length, 2); + assert.equal(mapApprovals.every((bookmark) => bookmark.collection === 'map_node_submissions'), true); + assert.deepEqual(mapApprovals[0]?.filter, { + status: { + _eq: 'pending', + }, + }); + assert.equal(failedAlerts.length, 2); + assert.equal(failedAlerts.every((bookmark) => bookmark.collection === 'map_node_moderation_notifications'), true); }); diff --git a/scripts/directus-studio-setup.ts b/scripts/directus-studio-setup.ts index ff8740c..c91f8e7 100644 --- a/scripts/directus-studio-setup.ts +++ b/scripts/directus-studio-setup.ts @@ -99,6 +99,28 @@ const STEWARD_WORKFLOW_COLLECTION_META = Object.freeze({ }, }); +const MAP_NODE_MODERATION_COLLECTIONS = Object.freeze([ + 'map_node_submissions', + 'map_node_moderation_notifications', +]); + +const MAP_NODE_MODERATION_COLLECTION_META = Object.freeze({ + map_node_submissions: { + hidden: false, + singleton: false, + icon: 'approval', + note: 'Private map-node intake. Review the submission, then set its moderation status.', + display_template: '{{ display_name }} · {{ place_name }}', + }, + map_node_moderation_notifications: { + hidden: false, + singleton: false, + icon: 'mark_email_unread', + note: 'Private delivery audit for map moderation alerts. Failed alerts are retained here for operator follow-up.', + display_template: '{{ kind }} · {{ status }}', + }, +}); + const PUBLICATION_STATUS_CHOICES = Object.freeze([ { text: 'Draft', value: 'draft' }, { text: 'Pending Review', value: 'pending_review' }, @@ -115,6 +137,22 @@ const REQUEST_STATUS_CHOICES = Object.freeze([ { text: 'Archived', value: 'archived' }, ]); +const MAP_NODE_STATUS_CHOICES = Object.freeze([ + { text: 'Pending approval', value: 'pending' }, + { text: 'Approved', value: 'approved' }, + { text: 'Rejected', value: 'rejected' }, + { text: 'Archived', value: 'archived' }, +]); + +const MAP_NODE_MODERATION_NOTIFICATION_STATUS_CHOICES = Object.freeze([ + { text: 'Queued', value: 'queued' }, + { text: 'Delivery claimed', value: 'delivery_claimed' }, + { text: 'Retry scheduled', value: 'retry_scheduled' }, + { text: 'Sent', value: 'sent' }, + { text: 'Failed', value: 'failed' }, + { text: 'Skipped', value: 'skipped' }, +]); + const ENTITY_STATUS_CHOICES = Object.freeze([ { text: 'Active', value: 'active' }, { text: 'Forming', value: 'forming' }, @@ -474,6 +512,62 @@ const FIELD_META_BY_COLLECTION = Object.freeze({ created_at: fieldMeta({ sort: 5, width: 'half', interface: 'datetime', readonly: true, hidden: true }), updated_at: fieldMeta({ sort: 6, width: 'half', interface: 'datetime', readonly: true, hidden: true }), }, + map_node_submissions: { + id: fieldMeta({ sort: 1, width: 'half', interface: 'input', readonly: true, hidden: true }), + status: fieldMeta({ + sort: 2, + width: 'half', + note: 'Approve only after reviewing the submission. Directus records the actor and status transition.', + interface: 'select-dropdown', + options: { choices: MAP_NODE_STATUS_CHOICES }, + display: 'labels', + required: true, + }), + display_name: fieldMeta({ sort: 3, width: 'half', interface: 'input', readonly: true }), + place_name: fieldMeta({ sort: 4, width: 'half', interface: 'input', readonly: true }), + city: fieldMeta({ sort: 5, width: 'half', interface: 'input', readonly: true }), + region: fieldMeta({ sort: 6, width: 'half', interface: 'input', readonly: true }), + country: fieldMeta({ sort: 7, width: 'half', interface: 'input', readonly: true }), + latitude: fieldMeta({ sort: 8, width: 'half', interface: 'input', options: { type: 'number' }, readonly: true }), + longitude: fieldMeta({ sort: 9, width: 'half', interface: 'input', options: { type: 'number' }, readonly: true }), + role: fieldMeta({ sort: 10, width: 'half', interface: 'input', readonly: true }), + chapter_slug: fieldMeta({ sort: 11, width: 'half', interface: 'input', readonly: true }), + themes: fieldMeta({ + sort: 12, + note: 'Public themes selected by the submitter.', + width: 'full', + interface: 'tags', + display: 'labels', + readonly: true, + }), + public_note: textarea(13, 'Public tagline submitted for the map.'), + approved_at: fieldMeta({ sort: 14, width: 'half', interface: 'datetime', readonly: true }), + created_at: fieldMeta({ sort: 15, width: 'half', interface: 'datetime', readonly: true }), + updated_at: fieldMeta({ sort: 16, width: 'half', interface: 'datetime', readonly: true }), + }, + map_node_moderation_notifications: { + id: fieldMeta({ sort: 1, width: 'half', interface: 'input', readonly: true, hidden: true }), + kind: fieldMeta({ sort: 2, width: 'half', interface: 'select-dropdown', readonly: true }), + submission_id: fieldMeta({ sort: 3, width: 'half', interface: 'input', readonly: true }), + digest_date: fieldMeta({ sort: 4, width: 'half', interface: 'datetime', readonly: true }), + status: fieldMeta({ + sort: 5, + width: 'half', + note: 'Delivery status is maintained by the agent. A failed alert remains visible here for operator follow-up.', + interface: 'select-dropdown', + options: { choices: MAP_NODE_MODERATION_NOTIFICATION_STATUS_CHOICES }, + display: 'labels', + readonly: true, + }), + attempts: fieldMeta({ sort: 6, width: 'half', interface: 'input', readonly: true }), + next_attempt_at: fieldMeta({ sort: 7, width: 'half', interface: 'datetime', readonly: true }), + delivery_claimed_at: fieldMeta({ sort: 8, width: 'half', interface: 'datetime', readonly: true }), + provider_message_id: fieldMeta({ sort: 9, width: 'full', interface: 'input', readonly: true }), + provider_error: fieldMeta({ sort: 10, width: 'full', interface: 'input', readonly: true }), + sent_at: fieldMeta({ sort: 11, width: 'half', interface: 'datetime', readonly: true }), + created_at: fieldMeta({ sort: 12, width: 'half', interface: 'datetime', readonly: true }), + updated_at: fieldMeta({ sort: 13, width: 'half', interface: 'datetime', readonly: true }), + }, }); function cleanCollectionName(collection) { @@ -503,12 +597,14 @@ function encodePathSegment(segment) { export function buildDirectusStudioMetadataPlan( operationalCollectionNames = DIRECTUS_OPERATIONAL_COLLECTIONS, accessCollectionNames = DIRECTUS_STEWARD_ACCESS_COLLECTIONS, - workflowCollectionNames = DIRECTUS_STEWARD_WORKFLOW_COLLECTIONS + workflowCollectionNames = DIRECTUS_STEWARD_WORKFLOW_COLLECTIONS, + mapModerationCollectionNames = MAP_NODE_MODERATION_COLLECTIONS ) { const collections = [ ...operationalCollectionNames, ...accessCollectionNames, ...workflowCollectionNames, + ...mapModerationCollectionNames, ]; return { @@ -516,7 +612,8 @@ export function buildDirectusStudioMetadataPlan( const base = cleanCollectionName(collection); const meta = OPERATIONAL_COLLECTION_META[base] ?? ACCESS_COLLECTION_META[base] ?? - STEWARD_WORKFLOW_COLLECTION_META[base]; + STEWARD_WORKFLOW_COLLECTION_META[base] ?? + MAP_NODE_MODERATION_COLLECTION_META[base]; if (!meta) throw new Error(`Missing Directus Studio collection metadata for ${base}.`); return { collection, @@ -588,11 +685,48 @@ const STUDIO_BOOKMARKS = Object.freeze([ filter: { request_status: { _eq: 'pending_review' } }, fields: ['title', 'chapter_slug', 'request_status', 'updated_at'], }, + { + role: 'Greenpill Steward Moderator', + collection: 'map_node_submissions', + bookmark: 'Pending map node approvals', + icon: 'approval', + color: '#005c8a', + filter: { status: { _eq: 'pending' } }, + fields: ['display_name', 'place_name', 'themes', 'status', 'created_at'], + }, + { + role: 'Greenpill Trusted Publisher', + collection: 'map_node_submissions', + bookmark: 'Pending map node approvals', + icon: 'approval', + color: '#005c8a', + filter: { status: { _eq: 'pending' } }, + fields: ['display_name', 'place_name', 'themes', 'status', 'created_at'], + }, + { + role: 'Greenpill Steward Moderator', + collection: 'map_node_moderation_notifications', + bookmark: 'Failed map moderation alerts', + icon: 'mark_email_unread', + color: '#b42318', + filter: { status: { _eq: 'failed' } }, + fields: ['submission_id', 'kind', 'attempts', 'provider_error', 'updated_at'], + }, + { + role: 'Greenpill Trusted Publisher', + collection: 'map_node_moderation_notifications', + bookmark: 'Failed map moderation alerts', + icon: 'mark_email_unread', + color: '#b42318', + filter: { status: { _eq: 'failed' } }, + fields: ['submission_id', 'kind', 'attempts', 'provider_error', 'updated_at'], + }, ]); export function buildDirectusStudioBookmarkPlan(collectionNames = [ ...DIRECTUS_OPERATIONAL_COLLECTIONS, ...DIRECTUS_STEWARD_WORKFLOW_COLLECTIONS, + ...MAP_NODE_MODERATION_COLLECTIONS, ]) { const collectionByBase = new Map(collectionNames.map((collection) => [cleanCollectionName(collection), collection])); @@ -754,7 +888,13 @@ export async function applyDirectusStudioMetadata(options: { const operationalCollections = resolveSchemaCollectionNames(available, 'content', DIRECTUS_OPERATIONAL_COLLECTIONS); const accessCollections = resolveSchemaCollectionNames(available, 'content', DIRECTUS_STEWARD_ACCESS_COLLECTIONS); const workflowCollections = resolveSchemaCollectionNames(available, 'content', DIRECTUS_STEWARD_WORKFLOW_COLLECTIONS); - const plan = buildDirectusStudioMetadataPlan(operationalCollections, accessCollections, workflowCollections); + const mapModerationCollections = resolveSchemaCollectionNames(available, 'intake', MAP_NODE_MODERATION_COLLECTIONS); + const plan = buildDirectusStudioMetadataPlan( + operationalCollections, + accessCollections, + workflowCollections, + mapModerationCollections + ); const relationKeys = await getRelationKeys(client); for (const collection of plan.collections) { @@ -770,6 +910,7 @@ export async function applyDirectusStudioMetadata(options: { const bookmarks = await applyDirectusStudioBookmarks(client, [ ...operationalCollections, ...workflowCollections, + ...mapModerationCollections, ]); return { diff --git a/scripts/map-node-contract.test.ts b/scripts/map-node-contract.test.ts index 307e158..14e33b5 100644 --- a/scripts/map-node-contract.test.ts +++ b/scripts/map-node-contract.test.ts @@ -1233,6 +1233,14 @@ test('map-node edit flow has an operator cleanup command', async () => { new URL('../packages/agent/migrations/017_map_node_edit_link_delivery_claims.sql', import.meta.url), 'utf8' ); + const moderationDeliveryScript = await readFile( + new URL('../scripts/map-node-moderation-delivery.ts', import.meta.url), + 'utf8' + ); + const moderationSql = await readFile( + new URL('../packages/agent/migrations/019_map_node_moderation_notifications.sql', import.meta.url), + 'utf8' + ); assert.equal( packageJson.scripts['db:cleanup:map-node-edit-flow'], @@ -1242,6 +1250,18 @@ test('map-node edit flow has an operator cleanup command', async () => { packageJson.scripts['db:deliver:map-node-edit-links'], 'bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-edit-link-delivery.ts' ); + assert.equal( + packageJson.scripts['db:deliver:map-node-moderation'], + 'bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-moderation-delivery.ts' + ); + assert.equal( + packageJson.scripts['test:agent'], + 'bun run build:packages && bun test scripts/agent-contract.test.ts && bun test scripts/map-node-moderation.test.ts' + ); + assert.equal( + packageJson.scripts['directus:map-moderation:smoke'], + 'bun --no-env-file scripts/directus-map-moderation-smoke.ts' + ); assert.equal( packageJson.scripts['test:map-edit:browser'], 'bun run build:website && bun --no-env-file scripts/map-edit-browser-smoke.ts' @@ -1252,6 +1272,13 @@ test('map-node edit flow has an operator cleanup command', async () => { assert.match(deliveryScript, /DATABASE_URL is required/); assert.match(deliveryClaimSql, /add column if not exists delivery_claimed_at timestamptz/); assert.match(deliveryClaimSql, /map_node_edit_tokens_delivery_queue_idx/); + assert.match(moderationDeliveryScript, /deliverQueuedModerationNotifications/); + assert.match(moderationDeliveryScript, /DATABASE_URL is required/); + assert.match(moderationSql, /create table if not exists intake\.map_node_moderation_notifications/); + assert.match(moderationSql, /map_node_moderation_notification_submission_idx/); + assert.match(moderationSql, /map_node_submission_moderation_transition/); + assert.match(moderationSql, /new\.approved_at = now\(\)/); + assert.doesNotMatch(moderationSql, /recipient|owner_email|raw_note|ip_address/i); }); test('home map tints resting lines faintly and reserves full theme colour for selection', async () => { diff --git a/scripts/map-node-moderation-delivery.ts b/scripts/map-node-moderation-delivery.ts new file mode 100644 index 0000000..730fe60 --- /dev/null +++ b/scripts/map-node-moderation-delivery.ts @@ -0,0 +1,13 @@ +import { createMapNodeRepository } from '@greenpill-network/agent'; + +try { + const result = await createMapNodeRepository().deliverQueuedModerationNotifications(); + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code === 'database_not_configured') { + console.error('DATABASE_URL is required to deliver queued map-node moderation notifications.'); + process.exit(1); + } + throw error; +} diff --git a/scripts/map-node-moderation.test.ts b/scripts/map-node-moderation.test.ts new file mode 100644 index 0000000..511937a --- /dev/null +++ b/scripts/map-node-moderation.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + deliverQueuedMapNodeModerationNotifications, +} from '../packages/agent/src/map-node-moderation.ts'; + +function createModerationSql(rows) { + const statements = []; + const sql = async (strings, ...values) => { + const text = strings.join('?').replace(/\s+/g, ' ').trim(); + statements.push({ text, values }); + + if (text.includes('from intake.map_node_moderation_notifications notification')) return rows; + if (text.includes('returning attempts')) return [{ attempts: 1 }]; + return []; + }; + return { sql, statements }; +} + +test('moderation alerts contain only safe submission fields and the exact Directus record URL', async () => { + const submissionId = '35cd495c-8043-4aa8-9cc0-5e5469b4fb70'; + const { sql } = createModerationSql([{ + id: 'c8e1fc85-5ca5-43f9-bd13-5069eb6c544f', + kind: 'submission', + attempts: 0, + submissionId, + displayName: 'Map Member', + placeName: 'Oakland', + city: 'Oakland', + region: 'California', + country: 'United States', + lat: 37.8044, + long: -122.2712, + themes: ['public', 'events'], + publicNote: 'Growing a local network.', + createdAt: '2026-07-11T16:00:00.000Z', + email: 'private@example.org', + rawNote: 'Never email this.', + ipAddress: '203.0.113.42', + }]); + const calls = []; + + const result = await deliverQueuedMapNodeModerationNotifications(sql, { + now: new Date('2026-07-11T14:00:00.000Z'), + env: { + RESEND_API_KEY: 'resend-secret', + MAP_NODE_EMAIL_FROM: 'Greenpill Network ', + MAP_NODE_MODERATION_RECIPIENTS: 'afo@example.org, matt@example.org', + MAP_NODE_MODERATION_DIRECTUS_URL: 'https://admin.greenpill.network', + }, + fetchImpl: async (_url, options) => { + calls.push(options); + return Response.json({ id: 'email_123' }); + }, + }); + + assert.deepEqual(result, { queued: 1, delivered: 1, failed: 0, skipped: 0 }); + assert.equal(calls.length, 1); + const payload = JSON.parse(String(calls[0]?.body)); + assert.deepEqual(payload.to, ['afo@example.org', 'matt@example.org']); + assert.equal(payload.subject, 'Greenpill map node awaiting approval'); + assert.equal(calls[0]?.headers?.['Idempotency-Key'], 'map-node-moderation-c8e1fc85-5ca5-43f9-bd13-5069eb6c544f'); + assert.match(payload.text, new RegExp(`/admin/content/intake\\.map_node_submissions/${submissionId}`)); + assert.match(payload.text, /Map Member/); + assert.doesNotMatch(payload.text, /private@example\.org|Never email this|203\.0\.113\.42/); +}); diff --git a/scripts/public-content-contract.test.ts b/scripts/public-content-contract.test.ts index d347bc7..8780c79 100644 --- a/scripts/public-content-contract.test.ts +++ b/scripts/public-content-contract.test.ts @@ -552,6 +552,7 @@ test('Directus operational access plan separates draft editors from publishers', 'intake.map_node_submissions', 'intake.map_node_private_contacts', 'intake.map_node_reviews', + 'intake.map_node_moderation_notifications', 'intake.map_node_intake_settings', 'intake.map_node_edit_tokens', 'intake.map_node_update_requests', @@ -587,11 +588,21 @@ test('Directus operational access plan separates draft editors from publishers', permission.collection === 'intake.map_node_private_contacts' && permission.action === 'read' )); + const moderatorModerationNotificationRead = plan.permissions.find((permission) => ( + permission.role === 'Greenpill Steward Moderator' && + permission.collection === 'intake.map_node_moderation_notifications' && + permission.action === 'read' + )); const trustedPrivateContactRead = plan.permissions.find((permission) => ( permission.role === 'Greenpill Trusted Publisher' && permission.collection === 'intake.map_node_private_contacts' && permission.action === 'read' )); + const trustedModerationNotificationRead = plan.permissions.find((permission) => ( + permission.role === 'Greenpill Trusted Publisher' && + permission.collection === 'intake.map_node_moderation_notifications' && + permission.action === 'read' + )); const moderatorUpdateRequestRead = plan.permissions.find((permission) => ( permission.role === 'Greenpill Steward Moderator' && permission.collection === 'intake.map_node_update_requests' && @@ -628,6 +639,11 @@ test('Directus operational access plan separates draft editors from publishers', assert.equal(moderatorSubmissionRead.fields.includes('ip_address'), false); assert.equal(moderatorSubmissionRead.fields.includes('user_agent'), false); assert.equal(moderatorPrivateContactRead, undefined); + assert.ok(moderatorModerationNotificationRead); + assert.ok(trustedModerationNotificationRead); + assert.equal(moderatorModerationNotificationRead.fields.includes('provider_error'), true); + assert.equal(moderatorModerationNotificationRead.fields.includes('email'), false); + assert.equal(moderatorModerationNotificationRead.fields.includes('recipient'), false); assert.ok(moderatorUpdateRequestRead); assert.equal(moderatorUpdateRequestRead.fields.includes('current_public_fields'), true); assert.equal(moderatorUpdateRequestRead.fields.includes('proposed_public_fields'), true);