feat: outreach contacts — track hand-written DMs per collective

A list of public accounts a collective wants to write to (artists,
collectives, public figures), with a drafted message per contact and the
state of the conversation: todo, sent, replied, declined.

The Hub does not send these messages. "Copy & open profile" puts the
draft on the clipboard and opens the profile; a person sends it from the
platform's own DM box and marks it sent. Unsolicited bulk DMs break the
terms of every commercial platform and get accounts restricted, and
Instagram's API cannot open a conversation at all.

API (OutreachModule):
- migration 041: outreach_contacts, RLS enabled + forced with WITH CHECK,
  unique per (workspace, platform, lower(handle)); permissions
  outreach.view (Owner/Admin/Editor/Moderator) and outreach.manage
  (Owner/Admin/Editor).
- GET/POST/PATCH/DELETE /outreach/contacts; bulk import up to 500 rows,
  known accounts skipped. Audit records list changes and status moves,
  never the message text.

Web: /outreach page (sidebar + More), CSV import, {name}/{handle}
template for pending contacts, status filters, a daily counter that
warns past 20 first messages.

Tests: service and request parsing with the DB faked; the migration and
every service query were also run against PGlite as the non-superuser
app role (tenant isolation, conflict skip, status timestamps).
This commit is contained in:
hacklab 2026-10-15 14:15:03 +02:00
parent 80aa198843
commit ad0f1be6cf
13 changed files with 1025 additions and 3 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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<OutreachContact[]> {
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<OutreachContact> {
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<void> {
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;
}

View file

@ -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 {}

View file

@ -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<OutreachContact[]> {
return withTenant(workspaceId, async (client) => {
const result = await client.query<OutreachRow>(
`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<OutreachContact> {
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<OutreachRow>(
`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<void> {
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<string, string | null> {
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,
};
}

View file

@ -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<string, unknown> = {}) => ({
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<unknown>) =>
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();
});
});

View file

@ -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:^",

View file

@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
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<string, { main: number; nested: number[] }>();

View file

@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
<div className="border-y border-border-subtle bg-surface">
<Row href="/connections" title="Connections" detail="Accounts and health" />
<Row href="/outreach" title="Outreach" detail="Contacts to write to by hand" />
<Row href="/settings" title="Settings" />
<Row href="/settings" title="Security & sessions" />
<Row href="/calendar" title="Calendar" />

View file

@ -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<OutreachStatus, "neutral" | "accent" | "live" | "danger"> = {
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<OutreachContact[], OutreachContact[]>("/outreach/contacts", identity, SAMPLE);
const live = contacts.source === "live";
const canManage = live && can("outreach.manage");
const [filter, setFilter] = useState<OutreachStatus | "all">("todo");
const [category, setCategory] = useState<string>("all");
const [openId, setOpenId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(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<OutreachStatus, number>;
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<Pick<OutreachContact, "message" | "notes" | "status">>): Promise<void> {
try {
replace(await api.patch<OutreachContact>(`/outreach/contacts/${id}`, body));
} catch (err) {
setNotice(errorMessage(err));
}
}
async function copyAndOpen(contact: OutreachContact): Promise<void> {
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<void> {
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<void> {
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 (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">Outreach</h1>
<OfflineBadge source={contacts.source} />
{!live && <Badge tone="warn">read-only sample</Badge>}
</div>
<p className="mb-4 max-w-2xl text-xs leading-relaxed text-text-faint">
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.
</p>
<div className="mb-4 flex flex-wrap items-center gap-2">
{(["all", ...OUTREACH_STATUSES] as const).map((status) => (
<Button key={status} size="sm" variant={filter === status ? "outline" : "ghost"} onClick={() => setFilter(status)}>
{status} {status === "all" ? contacts.data.length : counts[status]}
</Button>
))}
{categories.length > 0 && (
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-2xs"
aria-label="Category"
>
<option value="all">all categories</option>
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
)}
<span className="ml-auto text-2xs text-text-faint">
Sent today: <span className={today >= OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today}</span>
</span>
</div>
{today >= OUTREACH_DAILY_SOFT_CAP && (
<Card className="mb-4 p-3">
<p className="text-xs text-warn">
{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.
</p>
</Card>
)}
{(notice !== null || contacts.error !== null) && (
<Card className="mb-4 flex items-start justify-between gap-3 p-3">
<p className={`text-xs ${contacts.error !== null ? "text-danger" : "text-text-dim"}`}>{contacts.error ?? notice}</p>
{notice !== null && <Button size="sm" onClick={() => setNotice(null)}>Dismiss</Button>}
</Card>
)}
{canManage && <Tools busy={busy} onImported={contacts.reload} onTemplate={applyTemplate} onNotice={setNotice} />}
{contacts.source === "loading" ? (
<div className="flex justify-center py-10"><Loading size={28} label="Loading contacts" /></div>
) : visible.length === 0 ? (
<EmptyState title="No contacts here" hint={canManage ? "Import a list above or change the filter." : "Change the filter."} />
) : (
<div className="flex flex-col gap-2">
{visible.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
open={openId === contact.id}
canManage={canManage}
onToggle={() => setOpenId(openId === contact.id ? null : contact.id)}
onPatch={(body) => patch(contact.id, body)}
onCopyOpen={() => copyAndOpen(contact)}
onRemove={() => remove(contact)}
/>
))}
</div>
)}
</div>
);
}
function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: {
contact: OutreachContact;
open: boolean;
canManage: boolean;
onToggle: () => void;
onPatch: (body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>) => Promise<void>;
onCopyOpen: () => Promise<void>;
onRemove: () => Promise<void>;
}): React.ReactElement {
const [message, setMessage] = useState(contact.message);
const [notes, setNotes] = useState(contact.notes);
return (
<Card className="p-3">
<button type="button" onClick={onToggle} className="flex w-full flex-wrap items-center gap-2 text-left">
<span className="text-sm font-medium">{contact.name}</span>
<span className="text-2xs text-text-faint">{contact.platform} · {contact.handle}</span>
{contact.category !== null && <Badge>{contact.category}</Badge>}
{contact.language !== null && <Badge>{contact.language}</Badge>}
<span className="ml-auto"><Badge tone={STATUS_TONE[contact.status]}>{contact.status}</Badge></span>
</button>
{open && (
<div className="mt-3 flex flex-col gap-2">
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onBlur={() => { if (canManage && message !== contact.message) void onPatch({ message }); }}
rows={5}
maxLength={4000}
readOnly={!canManage}
placeholder="Message for this contact"
className={TEXTAREA}
/>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
onBlur={() => { if (canManage && notes !== contact.notes) void onPatch({ notes }); }}
rows={2}
maxLength={2000}
readOnly={!canManage}
placeholder="Notes (who wrote, what they said)"
className={TEXTAREA}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void onCopyOpen()}>Copy &amp; open profile</Button>
{canManage && contact.status !== "sent" && <Button variant="outline" size="sm" onClick={() => void onPatch({ status: "sent" })}>Mark sent</Button>}
{canManage && contact.status !== "replied" && <Button size="sm" onClick={() => void onPatch({ status: "replied" })}>Replied</Button>}
{canManage && contact.status !== "declined" && <Button size="sm" onClick={() => void onPatch({ status: "declined" })}>Declined</Button>}
{canManage && contact.status !== "todo" && <Button size="sm" onClick={() => void onPatch({ status: "todo" })}>Back to pending</Button>}
{canManage && <Button variant="danger" size="sm" className="ml-auto" onClick={() => void onRemove()}>Remove</Button>}
</div>
{contact.sentAt !== null && (
<p className="text-2xs text-text-faint">
Sent {new Date(contact.sentAt).toLocaleString()}
{contact.repliedAt !== null && ` · replied ${new Date(contact.repliedAt).toLocaleString()}`}
</p>
)}
</div>
)}
</Card>
);
}
function Tools({ busy, onImported, onTemplate, onNotice }: {
busy: boolean;
onImported: () => void;
onTemplate: (template: string) => Promise<void>;
onNotice: (message: string) => void;
}): React.ReactElement {
const [csv, setCsv] = useState("");
const [template, setTemplate] = useState("");
const [importing, setImporting] = useState(false);
async function submit(): Promise<void> {
const { rows, errors } = parseImport(csv);
if (errors.length > 0) {
onNotice(errors.slice(0, 3).join("; "));
return;
}
if (rows.length === 0) return;
setImporting(true);
try {
let added = 0;
let skipped = 0;
for (let start = 0; start < rows.length; start += 500) {
const result = await api.post<{ added: number; skipped: number }>("/outreach/contacts", { contacts: rows.slice(start, start + 500) });
added += result.added;
skipped += result.skipped;
}
onNotice(`${added} contacts added, ${skipped} already on the list.`);
setCsv("");
onImported();
} catch (err) {
onNotice(errorMessage(err));
} finally {
setImporting(false);
}
}
return (
<div className="mb-4 grid gap-3 lg:grid-cols-2">
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Import</h2>
<p className="mb-2 text-2xs text-text-faint">One per line: name, platform, handle, profileUrl, category, language</p>
<textarea
value={csv}
onChange={(event) => setCsv(event.target.value)}
rows={4}
placeholder="Name,platform,handle,https://profile-url,category,es"
className={`${TEXTAREA} font-mono text-2xs`}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={importing || csv.trim() === ""} onClick={() => void submit()}>
{importing ? "Importing…" : "Import"}
</Button>
</Card>
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Template</h2>
<p className="mb-2 text-2xs text-text-faint">
Fills pending contacts in the current view that have no message yet. Use {"{name}"} and {"{handle}"}.
</p>
<textarea
value={template}
onChange={(event) => setTemplate(event.target.value)}
rows={4}
placeholder="Hola {name}, os escribimos desde la Acampada 26S…"
className={TEXTAREA}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={busy || template.trim() === ""} onClick={() => void onTemplate(template)}>
{busy ? "Applying…" : "Apply to pending"}
</Button>
</Card>
</div>
);
}

View file

@ -18,6 +18,7 @@ const NAV: { href: string; label: string; icon: string; external?: boolean }[] =
{ href: "/files", label: "Files", icon: "files" },
{ href: "/archive", label: "Archive", icon: "files" },
{ href: "/inbox", label: "Inbox", icon: "inbox" },
{ href: "/outreach", label: "Outreach", icon: "team" },
{ href: "/approvals", label: "Approvals", icon: "approvals" },
{ href: "/video", label: "Video", icon: "video" },
{ href: "/analytics", label: "Analytics", icon: "analytics" },

View file

@ -0,0 +1,30 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { fillTemplate, parseImport, sentToday, type OutreachContact } from "./outreach-types";
test("parseImport reads quoted fields, skips header, blanks and comments", () => {
const { rows, errors } = parseImport([
"name,platform,handle,profileUrl,category,language",
"# afines",
"",
'"Stop the War, UK",Web,@STWuk,https://example.org/stw,paz,en',
].join("\n"));
assert.deepEqual(errors, []);
assert.deepEqual(rows, [{ name: "Stop the War, UK", platform: "web", handle: "@STWuk", profileUrl: "https://example.org/stw", category: "paz", language: "en" }]);
});
test("parseImport reports missing columns and non-https links by line", () => {
const { rows, errors } = parseImport("A,web,@a\nB,web,@b,http://example.org/b");
assert.deepEqual(rows, []);
assert.deepEqual(errors, ["line 1: needs name, platform, handle and profileUrl", "line 2: profileUrl must start with https://"]);
});
test("fillTemplate replaces every placeholder", () => {
assert.equal(fillTemplate("Hola {name} ({handle}), {name}", { name: "DAL", handle: "@dal" }), "Hola DAL (@dal), DAL");
});
test("sentToday counts first messages sent on the same local day", () => {
const now = new Date(2026, 9, 15, 18);
const at = (d: Date | null) => ({ sentAt: d === null ? null : d.toISOString() }) as OutreachContact;
assert.equal(sentToday([at(new Date(2026, 9, 15, 9)), at(new Date(2026, 9, 14, 23)), at(null)], now), 1);
});

View file

@ -0,0 +1,93 @@
/** Mirrors apps/api/src/outreach/outreach.service.ts (OutreachContact). */
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
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 OutreachImportRow {
name: string;
platform: string;
handle: string;
profileUrl: string;
category?: string;
language?: string;
}
/** First messages per day above which platforms start flagging an account. */
export const OUTREACH_DAILY_SOFT_CAP = 20;
export const IMPORT_COLUMNS = ["name", "platform", "handle", "profileUrl", "category", "language"] as const;
/**
* Parse pasted CSV (one contact per line, IMPORT_COLUMNS order, optional
* header). Double quotes wrap fields that contain commas. Blank lines and
* lines starting with # are skipped. Returns rows plus per-line errors.
*/
export function parseImport(text: string): { rows: OutreachImportRow[]; errors: string[] } {
const rows: OutreachImportRow[] = [];
const errors: string[] = [];
text.split(/\r?\n/).forEach((line, index) => {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) return;
const cells = splitCsvLine(trimmed);
if (index === 0 && cells[0]?.toLowerCase() === "name") return;
const [name, platform, handle, profileUrl, category, language] = cells;
if (!name || !platform || !handle || !profileUrl) {
errors.push(`line ${index + 1}: needs name, platform, handle and profileUrl`);
return;
}
if (!profileUrl.startsWith("https://")) {
errors.push(`line ${index + 1}: profileUrl must start with https://`);
return;
}
const row: OutreachImportRow = { name, platform: platform.toLowerCase(), handle, profileUrl };
if (category) row.category = category;
if (language) row.language = language;
rows.push(row);
});
return { rows, errors };
}
function splitCsvLine(line: string): string[] {
const cells: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
if (quoted) {
if (char === '"' && line[i + 1] === '"') { current += '"'; i += 1; }
else if (char === '"') quoted = false;
else current += char;
} else if (char === '"') quoted = true;
else if (char === ",") { cells.push(current.trim()); current = ""; }
else current += char;
}
cells.push(current.trim());
return cells;
}
/** Fill a template: {name} and {handle} are replaced per contact. */
export function fillTemplate(template: string, contact: Pick<OutreachContact, "name" | "handle">): string {
return template.replaceAll("{name}", contact.name).replaceAll("{handle}", contact.handle);
}
export function sentToday(contacts: OutreachContact[], now = new Date()): number {
const day = now.toDateString();
return contacts.filter((contact) => contact.sentAt !== null && new Date(contact.sentAt).toDateString() === day).length;
}