diff --git a/apps/api/migrations/041_outreach.sql b/apps/api/migrations/041_outreach.sql new file mode 100644 index 0000000..b454a62 --- /dev/null +++ b/apps/api/migrations/041_outreach.sql @@ -0,0 +1,51 @@ +-- 041: outreach — per-workspace list of public accounts to contact by hand. +-- +-- The Hub never sends these messages. A person opens the contact's profile, +-- pastes the drafted text into the platform's own DM box and marks the row +-- sent; replies are marked the same way. Only public accounts belong here: +-- names and contact details of private persons stay out of the Hub. + +CREATE TABLE outreach_contacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + platform text NOT NULL CHECK (platform ~ '^[a-z0-9][a-z0-9-]{1,31}$'), + handle text NOT NULL CHECK (char_length(handle) BETWEEN 1 AND 200), + profile_url text NOT NULL CHECK (profile_url ~ '^https://[^\s]+$' AND char_length(profile_url) <= 500), + category text CHECK (category IS NULL OR char_length(category) <= 100), + language text CHECK (language IS NULL OR language ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$'), + message text NOT NULL DEFAULT '' CHECK (char_length(message) <= 4000), + notes text NOT NULL DEFAULT '' CHECK (char_length(notes) <= 2000), + status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'sent', 'replied', 'declined')), + sent_at timestamptz, + sent_by uuid REFERENCES users(id) ON DELETE SET NULL, + replied_at timestamptz, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, workspace_id) +); + +CREATE UNIQUE INDEX outreach_contacts_account ON outreach_contacts (workspace_id, platform, lower(handle)); +CREATE INDEX outreach_contacts_status ON outreach_contacts (workspace_id, status); + +CREATE TRIGGER trg_outreach_contacts_ws_immutable BEFORE UPDATE ON outreach_contacts + FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); + +ALTER TABLE outreach_contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE outreach_contacts FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON outreach_contacts + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) + WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); +GRANT SELECT, INSERT, UPDATE, DELETE ON outreach_contacts TO sp_app; + +-- Reads for anyone who already sees the inbox; edits for the roles that write drafts. +INSERT INTO role_permissions (role, permission) +SELECT 'Owner', 'outreach.view' +UNION ALL SELECT 'Admin', 'outreach.view' +UNION ALL SELECT 'Editor', 'outreach.view' +UNION ALL SELECT 'Moderator', 'outreach.view' +UNION ALL SELECT 'Owner', 'outreach.manage' +UNION ALL SELECT 'Admin', 'outreach.manage' +UNION ALL SELECT 'Editor', 'outreach.manage' +ON CONFLICT DO NOTHING; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ee1dd29..1e0e693 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -43,9 +43,10 @@ import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js"; import { OidcModule } from "./oidc/oidc.module.js"; import { SubmissionsModule } from "./submissions/submissions.module.js"; import { MediaModule } from "./media/media.module.js"; +import { OutreachModule } from "./outreach/outreach.module.js"; @Module({ - imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule], + imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule, OutreachModule], controllers: [ DemoController, PublishingController, diff --git a/apps/api/src/outreach/outreach.controller.ts b/apps/api/src/outreach/outreach.controller.ts new file mode 100644 index 0000000..7a0adb8 --- /dev/null +++ b/apps/api/src/outreach/outreach.controller.ts @@ -0,0 +1,141 @@ +/** + * Outreach contacts (manual DM campaign tracking). + * + * GET /outreach/contacts?status= outreach.view — list (≤ 2000) + * POST /outreach/contacts outreach.manage — { contacts: [...] } bulk add, ≤ 500 + * PATCH /outreach/contacts/:id outreach.manage — { message?, notes?, status? } + * DELETE /outreach/contacts/:id outreach.manage + * + * No route sends a message: see OutreachService. + */ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { requireObject, requireSession } from "../http/validation.js"; +import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; +import { + OUTREACH_MAX_IMPORT, + OutreachService, + isOutreachStatus, + type OutreachContact, + type OutreachContactInput, + type OutreachContactPatch, + type OutreachStatus, +} from "./outreach.service.js"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PLATFORM = /^[a-z0-9][a-z0-9-]{1,31}$/; +const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/; + +@Controller("outreach") +@UseGuards(RbacGuard) +export class OutreachController { + constructor(@Inject(OutreachService) private readonly outreach: OutreachService) {} + + @Get("contacts") + @RequirePermission("outreach.view") + async list(@Req() request: AuthenticatedRequest, @Query("status") status?: string): Promise { + const session = requireSession(request); + if (status !== undefined && !isOutreachStatus(status)) throw new BadRequestException("unknown status"); + return this.outreach.list(session.workspaceId, status as OutreachStatus | undefined); + } + + @Post("contacts") + @RequirePermission("outreach.manage") + async import(@Req() request: AuthenticatedRequest, @Body() body: unknown): Promise<{ added: number; skipped: number }> { + const session = requireSession(request); + const raw = requireObject(body, "body").contacts; + if (!Array.isArray(raw) || raw.length === 0 || raw.length > OUTREACH_MAX_IMPORT) { + throw new BadRequestException(`contacts must be an array of 1 to ${OUTREACH_MAX_IMPORT} items`); + } + const contacts = raw.map((item, index) => parseContact(item, `contacts[${index}]`)); + return this.outreach.import(session.workspaceId, contacts, { userId: session.userId, role: session.role }); + } + + @Patch("contacts/:id") + @RequirePermission("outreach.manage") + async update(@Req() request: AuthenticatedRequest, @Param("id") id: string, @Body() body: unknown): Promise { + const session = requireSession(request); + if (!UUID.test(id)) throw new BadRequestException("id must be a UUID"); + return this.outreach.update(session.workspaceId, id, parsePatch(body), { userId: session.userId, role: session.role }); + } + + @Delete("contacts/:id") + @HttpCode(204) + @RequirePermission("outreach.manage") + async remove(@Req() request: AuthenticatedRequest, @Param("id") id: string): Promise { + const session = requireSession(request); + if (!UUID.test(id)) throw new BadRequestException("id must be a UUID"); + await this.outreach.remove(session.workspaceId, id, { userId: session.userId, role: session.role }); + } +} + +function text(value: unknown, field: string, max: number, required: boolean): string | undefined { + if (value === undefined || value === null || value === "") { + if (required) throw new BadRequestException(`${field} is required`); + return undefined; + } + if (typeof value !== "string") throw new BadRequestException(`${field} must be a string`); + const trimmed = value.trim(); + if (required && trimmed === "") throw new BadRequestException(`${field} is required`); + if (trimmed.length > max) throw new BadRequestException(`${field} must be at most ${max} characters`); + return trimmed; +} + +export function parseContact(value: unknown, field: string): OutreachContactInput { + const item = requireObject(value, field); + const name = text(item.name, `${field}.name`, 200, true) as string; + const platform = (text(item.platform, `${field}.platform`, 32, true) as string).toLowerCase(); + if (!PLATFORM.test(platform)) throw new BadRequestException(`${field}.platform must be a lowercase id`); + const handle = text(item.handle, `${field}.handle`, 200, true) as string; + const profileUrl = text(item.profileUrl, `${field}.profileUrl`, 500, true) as string; + let url: URL; + try { + url = new URL(profileUrl); + } catch { + throw new BadRequestException(`${field}.profileUrl must be a URL`); + } + if (url.protocol !== "https:" || /\s/.test(profileUrl)) throw new BadRequestException(`${field}.profileUrl must be an https URL`); + const language = text(item.language, `${field}.language`, 12, false); + if (language !== undefined && !LANGUAGE.test(language)) throw new BadRequestException(`${field}.language must be a language tag such as es or pt-BR`); + const contact: OutreachContactInput = { name, platform, handle, profileUrl }; + const category = text(item.category, `${field}.category`, 100, false); + const message = text(item.message, `${field}.message`, 4000, false); + const notes = text(item.notes, `${field}.notes`, 2000, false); + if (category !== undefined) contact.category = category; + if (language !== undefined) contact.language = language; + if (message !== undefined) contact.message = message; + if (notes !== undefined) contact.notes = notes; + return contact; +} + +export function parsePatch(value: unknown): OutreachContactPatch { + const body = requireObject(value, "body"); + const patch: OutreachContactPatch = {}; + if (body.message !== undefined) { + if (typeof body.message !== "string" || body.message.length > 4000) throw new BadRequestException("message must be a string of at most 4000 characters"); + patch.message = body.message; + } + if (body.notes !== undefined) { + if (typeof body.notes !== "string" || body.notes.length > 2000) throw new BadRequestException("notes must be a string of at most 2000 characters"); + patch.notes = body.notes; + } + if (body.status !== undefined) { + if (!isOutreachStatus(body.status)) throw new BadRequestException("status must be todo, sent, replied or declined"); + patch.status = body.status; + } + if (Object.keys(patch).length === 0) throw new BadRequestException("nothing to update"); + return patch; +} diff --git a/apps/api/src/outreach/outreach.module.ts b/apps/api/src/outreach/outreach.module.ts new file mode 100644 index 0000000..fd0d6f0 --- /dev/null +++ b/apps/api/src/outreach/outreach.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { OutreachController } from "./outreach.controller.js"; +import { OutreachService } from "./outreach.service.js"; + +/** + * Outreach contacts: the list of public accounts a collective writes to by + * hand, with the drafted message and the sent/replied state per contact. + */ +@Module({ + controllers: [OutreachController], + providers: [OutreachService], +}) +export class OutreachModule {} diff --git a/apps/api/src/outreach/outreach.service.ts b/apps/api/src/outreach/outreach.service.ts new file mode 100644 index 0000000..89ac18e --- /dev/null +++ b/apps/api/src/outreach/outreach.service.ts @@ -0,0 +1,229 @@ +/** + * Outreach: a workspace's list of public accounts to contact one by one. + * + * The Hub stores the contact, a drafted message and where the conversation + * stands (todo → sent → replied/declined). It never sends anything: the + * person copies the text, opens the profile and writes from the platform's + * own DM box. Platforms forbid or rate-limit unsolicited bulk DMs, so + * sending stays human and the API has no provider calls here. + * + * Audit entries record status changes and membership of the list, never the + * message text. + */ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { appendTenantAudit } from "../audit/audit.service.js"; +import { withTenant } from "../db/client.js"; + +export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const; +export type OutreachStatus = (typeof OUTREACH_STATUSES)[number]; + +/** One import call; larger lists go in several requests. */ +export const OUTREACH_MAX_IMPORT = 500; +export const OUTREACH_MAX_LIST = 2000; + +export interface OutreachContactInput { + name: string; + platform: string; + handle: string; + profileUrl: string; + category?: string; + language?: string; + message?: string; + notes?: string; +} + +export interface OutreachContactPatch { + message?: string; + notes?: string; + status?: OutreachStatus; +} + +export interface OutreachContact { + id: string; + name: string; + platform: string; + handle: string; + profileUrl: string; + category: string | null; + language: string | null; + message: string; + notes: string; + status: OutreachStatus; + sentAt: string | null; + repliedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OutreachActor { + userId: string; + role: string; +} + +interface OutreachRow { + id: string; + name: string; + platform: string; + handle: string; + profile_url: string; + category: string | null; + language: string | null; + message: string; + notes: string; + status: OutreachStatus; + sent_at: Date | null; + replied_at: Date | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = "id, name, platform, handle, profile_url, category, language, message, notes, status, sent_at, replied_at, created_at, updated_at"; + +function fromRow(row: OutreachRow): OutreachContact { + return { + id: row.id, + name: row.name, + platform: row.platform, + handle: row.handle, + profileUrl: row.profile_url, + category: row.category, + language: row.language, + message: row.message, + notes: row.notes, + status: row.status, + sentAt: row.sent_at?.toISOString() ?? null, + repliedAt: row.replied_at?.toISOString() ?? null, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +export function isOutreachStatus(value: unknown): value is OutreachStatus { + return typeof value === "string" && (OUTREACH_STATUSES as readonly string[]).includes(value); +} + +@Injectable() +export class OutreachService { + async list(workspaceId: string, status?: OutreachStatus): Promise { + return withTenant(workspaceId, async (client) => { + const result = await client.query( + `SELECT ${COLUMNS} FROM outreach_contacts + WHERE ($1::text IS NULL OR status = $1) + ORDER BY lower(name), platform + LIMIT ${OUTREACH_MAX_LIST}`, + [status ?? null], + ); + return result.rows.map(fromRow); + }); + } + + /** Bulk add; an account already on the list (same platform + handle) is skipped. */ + async import(workspaceId: string, contacts: OutreachContactInput[], actor: OutreachActor): Promise<{ added: number; skipped: number }> { + if (contacts.length === 0) throw new BadRequestException("contacts must not be empty"); + if (contacts.length > OUTREACH_MAX_IMPORT) { + throw new BadRequestException(`at most ${OUTREACH_MAX_IMPORT} contacts per import`); + } + const added = await withTenant(workspaceId, async (client) => { + const result = await client.query<{ id: string }>( + `INSERT INTO outreach_contacts + (workspace_id, name, platform, handle, profile_url, category, language, message, notes, created_by) + SELECT $1, c.name, c.platform, c.handle, c.profile_url, c.category, c.language, + coalesce(c.message, ''), coalesce(c.notes, ''), $2 + FROM jsonb_to_recordset($3::jsonb) AS c( + name text, platform text, handle text, profile_url text, + category text, language text, message text, notes text) + ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING + RETURNING id`, + [workspaceId, actor.userId, JSON.stringify(contacts.map(toRecord))], + ); + return result.rowCount ?? result.rows.length; + }); + if (added > 0) { + await appendTenantAudit(workspaceId, { + actorUserId: actor.userId, + actorRole: actor.role, + action: "outreach.imported", + targetType: "outreach_contact", + targetId: workspaceId, + diff: { added, skipped: contacts.length - added }, + }); + } + return { added, skipped: contacts.length - added }; + } + + /** + * Edit the draft/notes or move the status. Marking `sent` stamps who and + * when; `replied` stamps when; going back to `todo` clears both. + */ + async update(workspaceId: string, id: string, patch: OutreachContactPatch, actor: OutreachActor): Promise { + const { contact, previous } = await withTenant(workspaceId, async (client) => { + const current = await client.query<{ status: OutreachStatus }>( + "SELECT status FROM outreach_contacts WHERE id = $1 FOR UPDATE", + [id], + ); + const before = current.rows[0]; + if (before === undefined) throw new NotFoundException(`outreach contact ${id} not found`); + const status = patch.status ?? before.status; + const changed = status !== before.status; + const result = await client.query( + `UPDATE outreach_contacts SET + message = coalesce($2, message), + notes = coalesce($3, notes), + status = $4, + sent_at = CASE WHEN NOT $5 THEN sent_at WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN now() ELSE coalesce(sent_at, now()) END, + sent_by = CASE WHEN NOT $5 THEN sent_by WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN $6::uuid ELSE coalesce(sent_by, $6::uuid) END, + replied_at = CASE WHEN NOT $5 THEN replied_at WHEN $4 = 'replied' THEN now() ELSE NULL END, + updated_at = now() + WHERE id = $1 + RETURNING ${COLUMNS}`, + [id, patch.message ?? null, patch.notes ?? null, status, changed, actor.userId], + ); + const row = result.rows[0]; + if (row === undefined) throw new NotFoundException(`outreach contact ${id} not found`); + return { contact: fromRow(row), previous: before.status }; + }); + if (contact.status !== previous) { + await appendTenantAudit(workspaceId, { + actorUserId: actor.userId, + actorRole: actor.role, + action: `outreach.${contact.status}`, + targetType: "outreach_contact", + targetId: contact.id, + diff: { from: previous, to: contact.status, platform: contact.platform, handle: contact.handle }, + }); + } + return contact; + } + + async remove(workspaceId: string, id: string, actor: OutreachActor): Promise { + const removed = await withTenant(workspaceId, async (client) => { + const result = await client.query<{ platform: string; handle: string }>( + "DELETE FROM outreach_contacts WHERE id = $1 RETURNING platform, handle", + [id], + ); + return result.rows[0]; + }); + if (removed === undefined) throw new NotFoundException(`outreach contact ${id} not found`); + await appendTenantAudit(workspaceId, { + actorUserId: actor.userId, + actorRole: actor.role, + action: "outreach.removed", + targetType: "outreach_contact", + targetId: id, + diff: { platform: removed.platform, handle: removed.handle }, + }); + } +} + +function toRecord(contact: OutreachContactInput): Record { + return { + name: contact.name, + platform: contact.platform, + handle: contact.handle, + profile_url: contact.profileUrl, + category: contact.category ?? null, + language: contact.language ?? null, + message: contact.message ?? null, + notes: contact.notes ?? null, + }; +} diff --git a/apps/api/src/outreach/outreach.test.ts b/apps/api/src/outreach/outreach.test.ts new file mode 100644 index 0000000..a1ef69d --- /dev/null +++ b/apps/api/src/outreach/outreach.test.ts @@ -0,0 +1,136 @@ +import "reflect-metadata"; +import type { PoolClient } from "pg"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { appendTenantAudit } from "../audit/audit.service.js"; +import { parseContact, parsePatch } from "./outreach.controller.js"; +import { OutreachService } from "./outreach.service.js"; + +/** + * Outreach service + request parsing with the DB faked. RLS and the migration + * shape belong to a live-DB suite. + */ + +const dbMock = vi.hoisted(() => ({ withTenant: vi.fn() })); +vi.mock("../db/client.js", () => ({ withTenant: dbMock.withTenant })); +vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") })); + +const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10"; +const ID = "11111111-2222-4333-8444-555555555555"; +const ACTOR = { userId: "8f7f6e50-7b0f-4a2e-9d3e-2f2d1a0c5b21", role: "Editor" }; + +const row = (overrides: Record = {}) => ({ + id: ID, + name: "Climáximo", + platform: "bluesky", + handle: "climaximopt.bsky.social", + profile_url: "https://bsky.app/profile/climaximopt.bsky.social", + category: "clima", + language: "pt", + message: "Olá!", + notes: "", + status: "todo", + sent_at: null, + replied_at: null, + created_at: new Date("2026-10-15T10:00:00Z"), + updated_at: new Date("2026-10-15T10:00:00Z"), + ...overrides, +}); + +type Query = (sql: string, values?: unknown[]) => Promise<{ rows: unknown[]; rowCount?: number }>; + +function tenant(query: Query): Array<{ sql: string; values: unknown[] }> { + const calls: Array<{ sql: string; values: unknown[] }> = []; + dbMock.withTenant.mockImplementation(async (_ws: string, fn: (client: PoolClient) => Promise) => + fn({ + query: async (sql: string, values: unknown[] = []) => { + calls.push({ sql, values }); + return query(sql, values); + }, + } as unknown as PoolClient), + ); + return calls; +} + +beforeEach(() => { + dbMock.withTenant.mockReset(); + vi.mocked(appendTenantAudit).mockClear(); +}); + +describe("parseContact", () => { + const valid = { name: " DAL ", platform: "Instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" }; + + it("trims and lowercases the platform id", () => { + expect(parseContact(valid, "c")).toEqual({ name: "DAL", platform: "instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" }); + }); + + it("rejects non-https profile links and odd platform ids", () => { + expect(() => parseContact({ ...valid, profileUrl: "http://example.org/x" }, "c")).toThrow(/https/); + expect(() => parseContact({ ...valid, profileUrl: "javascript:alert(1)" }, "c")).toThrow(/https/); + expect(() => parseContact({ ...valid, platform: "x y" }, "c")).toThrow(/platform/); + expect(() => parseContact({ ...valid, language: "spanish" }, "c")).toThrow(/language/); + expect(() => parseContact({ ...valid, name: " " }, "c")).toThrow(/name/); + }); +}); + +describe("parsePatch", () => { + it("accepts the known statuses only and refuses an empty patch", () => { + expect(parsePatch({ status: "sent" })).toEqual({ status: "sent" }); + expect(() => parsePatch({ status: "spam" })).toThrow(/status/); + expect(() => parsePatch({})).toThrow(/nothing/); + }); +}); + +describe("OutreachService", () => { + const service = new OutreachService(); + + it("imports in one statement, skips known accounts and audits counts only", async () => { + const calls = tenant(async () => ({ rows: [{ id: ID }], rowCount: 1 })); + const result = await service.import(WS, [ + { name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a", message: "private draft" }, + { name: "B", platform: "bluesky", handle: "b", profileUrl: "https://bsky.app/profile/b" }, + ], ACTOR); + expect(result).toEqual({ added: 1, skipped: 1 }); + expect(calls).toHaveLength(1); + expect(calls[0]?.sql).toContain("ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING"); + expect(JSON.parse(String(calls[0]?.values[2]))).toHaveLength(2); + expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.imported", diff: { added: 1, skipped: 1 } })); + expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("private draft"); + }); + + it("does not audit an import that added nothing", async () => { + tenant(async () => ({ rows: [], rowCount: 0 })); + expect(await service.import(WS, [{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a" }], ACTOR)).toEqual({ added: 0, skipped: 1 }); + expect(appendTenantAudit).not.toHaveBeenCalled(); + }); + + it("marks a contact sent and audits the transition without the message", async () => { + const calls = tenant(async (sql) => { + if (sql.startsWith("SELECT status")) return { rows: [{ status: "todo" }] }; + if (sql.startsWith("UPDATE outreach_contacts")) return { rows: [row({ status: "sent", message: "Olá, secret text", sent_at: new Date("2026-10-15T11:00:00Z") })] }; + throw new Error(`unexpected query: ${sql}`); + }); + const contact = await service.update(WS, ID, { status: "sent", message: "Olá, secret text" }, ACTOR); + expect(contact.status).toBe("sent"); + expect(contact.sentAt).toBe("2026-10-15T11:00:00.000Z"); + expect(calls[1]?.values).toEqual([ID, "Olá, secret text", null, "sent", true, ACTOR.userId]); + expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.sent", diff: expect.objectContaining({ from: "todo", to: "sent" }) })); + expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("secret text"); + }); + + it("edits the draft without a status change and without an audit entry", async () => { + const calls = tenant(async (sql) => { + if (sql.startsWith("SELECT status")) return { rows: [{ status: "sent" }] }; + return { rows: [row({ status: "sent", message: "v2" })] }; + }); + await service.update(WS, ID, { message: "v2" }, ACTOR); + expect(calls[1]?.values).toEqual([ID, "v2", null, "sent", false, ACTOR.userId]); + expect(appendTenantAudit).not.toHaveBeenCalled(); + }); + + it("404s on an unknown contact", async () => { + tenant(async () => ({ rows: [] })); + await expect(service.update(WS, ID, { status: "sent" }, ACTOR)).rejects.toThrow(/not found/); + await expect(service.remove(WS, ID, ACTOR)).rejects.toThrow(/not found/); + expect(appendTenantAudit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 950a9ec..52b1cf8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,8 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test:manual": "node --require ./scripts/control-room-register.cjs --test src/lib/participation-manual.test.ts", - "test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts" + "test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts", + "test:outreach": "node --require ./scripts/control-room-register.cjs --test src/lib/outreach-types.test.ts" }, "dependencies": { "@tactical-media-broadcast-hub/connector-sdk": "workspace:^", diff --git a/apps/web/src/app/(main)/layout.tsx b/apps/web/src/app/(main)/layout.tsx index 2ab0dfb..28f48e0 100644 --- a/apps/web/src/app/(main)/layout.tsx +++ b/apps/web/src/app/(main)/layout.tsx @@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile"; const titles: Record = { streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post", calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team", - audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", + audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", outreach: "Outreach", }; const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]); const scrollPositions = new Map(); diff --git a/apps/web/src/app/(main)/more/page.tsx b/apps/web/src/app/(main)/more/page.tsx index 3f40f0e..39dc9bb 100644 --- a/apps/web/src/app/(main)/more/page.tsx +++ b/apps/web/src/app/(main)/more/page.tsx @@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {

Settings

+ diff --git a/apps/web/src/app/(main)/outreach/page.tsx b/apps/web/src/app/(main)/outreach/page.tsx new file mode 100644 index 0000000..1ec7cde --- /dev/null +++ b/apps/web/src/app/(main)/outreach/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Button, Card } from "../../../components/ui/primitives"; +import { Loading } from "../../../components/brand/loading"; +import { EmptyState } from "../../../components/brand/empty-state"; +import { OfflineBadge } from "../../../components/ui/offline-badge"; +import { api, errorMessage } from "../../../lib/api-client"; +import { useApiResource } from "../../../lib/use-api"; +import { useAuth } from "../../../lib/auth-store"; +import { + OUTREACH_DAILY_SOFT_CAP, + OUTREACH_STATUSES, + fillTemplate, + parseImport, + sentToday, + type OutreachContact, + type OutreachStatus, +} from "../../../lib/outreach-types"; + +/** + * Outreach — write to artists, collectives and public figures one by one. + * + * The list lives in GET /outreach/contacts. For each contact the screen keeps + * a drafted message; "Copy & open" puts it on the clipboard and opens the + * profile so a person sends it from the platform's own DM box, then marks the + * contact sent. Nothing is sent by the Hub: unsolicited bulk DMs get accounts + * suspended, and a personal message is what gets an answer. + */ + +const SAMPLE: OutreachContact[] = [ + { + id: "sample-1", name: "Sample collective", platform: "example", handle: "@sample@example.social", + profileUrl: "https://example.social/@sample", category: "housing", language: "en", message: "", notes: "", + status: "todo", sentAt: null, repliedAt: null, createdAt: "2026-10-15T00:00:00Z", updatedAt: "2026-10-15T00:00:00Z", + }, +]; + +const identity = (contacts: OutreachContact[]): OutreachContact[] => contacts; + +const STATUS_TONE: Record = { + todo: "neutral", sent: "accent", replied: "live", declined: "danger", +}; + +const TEXTAREA = "w-full rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none"; + +export default function OutreachPage(): React.ReactElement { + const { can } = useAuth(); + const contacts = useApiResource("/outreach/contacts", identity, SAMPLE); + const live = contacts.source === "live"; + const canManage = live && can("outreach.manage"); + const [filter, setFilter] = useState("todo"); + const [category, setCategory] = useState("all"); + const [openId, setOpenId] = useState(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + const categories = useMemo( + () => [...new Set(contacts.data.map((contact) => contact.category).filter((value): value is string => value !== null))].sort(), + [contacts.data], + ); + const visible = contacts.data.filter( + (contact) => (filter === "all" || contact.status === filter) && (category === "all" || contact.category === category), + ); + const counts = Object.fromEntries( + OUTREACH_STATUSES.map((status) => [status, contacts.data.filter((contact) => contact.status === status).length]), + ) as Record; + const today = sentToday(contacts.data); + + function replace(updated: OutreachContact): void { + contacts.setData((current) => current.map((contact) => (contact.id === updated.id ? updated : contact))); + } + + async function patch(id: string, body: Partial>): Promise { + try { + replace(await api.patch(`/outreach/contacts/${id}`, body)); + } catch (err) { + setNotice(errorMessage(err)); + } + } + + async function copyAndOpen(contact: OutreachContact): Promise { + if (contact.message.trim() !== "") { + try { + await navigator.clipboard.writeText(contact.message); + setNotice(`Message for ${contact.name} copied. Paste it in their DMs, then mark it sent.`); + } catch { + setNotice("Could not copy to the clipboard. Select the text and copy it by hand."); + } + } + window.open(contact.profileUrl, "_blank", "noopener,noreferrer"); + } + + async function remove(contact: OutreachContact): Promise { + if (!window.confirm(`Remove ${contact.name} from the list?`)) return; + try { + await api.del(`/outreach/contacts/${contact.id}`); + contacts.setData((current) => current.filter((row) => row.id !== contact.id)); + } catch (err) { + setNotice(errorMessage(err)); + } + } + + async function applyTemplate(template: string): Promise { + const targets = visible.filter((contact) => contact.status === "todo" && contact.message.trim() === ""); + if (targets.length === 0) { + setNotice("Every pending contact in this view already has a message."); + return; + } + setBusy(true); + for (const contact of targets) await patch(contact.id, { message: fillTemplate(template, contact) }); + setBusy(false); + setNotice(`Template applied to ${targets.length} contacts. Review each one before sending.`); + } + + return ( +
+
+

Outreach

+ + {!live && read-only sample} +
+

+ Public accounts to write to by hand. Draft a message per contact, copy it, open the profile and send it from + the platform. Mark who answered so nobody writes twice. +

+ +
+ {(["all", ...OUTREACH_STATUSES] as const).map((status) => ( + + ))} + {categories.length > 0 && ( + + )} + + Sent today: = OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today} + +
+ + {today >= OUTREACH_DAILY_SOFT_CAP && ( + +

+ {today} first messages today from this collective. Spread the rest over the next days: platforms + restrict accounts that send many first messages in a short time. +

+
+ )} + + {(notice !== null || contacts.error !== null) && ( + +

{contacts.error ?? notice}

+ {notice !== null && } +
+ )} + + {canManage && } + + {contacts.source === "loading" ? ( +
+ ) : visible.length === 0 ? ( + + ) : ( +
+ {visible.map((contact) => ( + setOpenId(openId === contact.id ? null : contact.id)} + onPatch={(body) => patch(contact.id, body)} + onCopyOpen={() => copyAndOpen(contact)} + onRemove={() => remove(contact)} + /> + ))} +
+ )} +
+ ); +} + +function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: { + contact: OutreachContact; + open: boolean; + canManage: boolean; + onToggle: () => void; + onPatch: (body: Partial>) => Promise; + onCopyOpen: () => Promise; + onRemove: () => Promise; +}): React.ReactElement { + const [message, setMessage] = useState(contact.message); + const [notes, setNotes] = useState(contact.notes); + + return ( + + + {open && ( +
+