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:
parent
80aa198843
commit
ad0f1be6cf
13 changed files with 1025 additions and 3 deletions
|
|
@ -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[] }>();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
325
apps/web/src/app/(main)/outreach/page.tsx
Normal file
325
apps/web/src/app/(main)/outreach/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue