T-HUB/apps/api/src/outreach/outreach.service.ts
hacklab ad0f1be6cf 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).
2026-10-15 14:15:03 +02:00

229 lines
8.2 KiB
TypeScript

/**
* 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,
};
}