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