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).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
51 lines
2.7 KiB
SQL
51 lines
2.7 KiB
SQL
-- 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;
|