From c96704fb6bf58cf31d15c84007acd11e20361361 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH 01/11] feat: wire the Instagram connector into connect and publishing The Instagram connector existed but was never registered, so Channels showed it as "provider unavailable" and publishing could not resolve it. - Register createInstagramConnector in the connections registry and the publishing registry; add the connector-runtime publishing policy (idempotency "none": media_publish has no key, never replay). - The Facebook app credentials identify the Hub, not a collective: read them only from INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. Drop the clientId/clientSecret connect fields and stop copying the app secret into each connection's vault entry. The provider is connectable only when both are set (connectEnabled + connectNote). - Fail the connect when several Instagram accounts are granted instead of silently publishing as the first Page's account. - Fix the connector package's pre-existing lint errors (it now ships in api). - Tests: contract cases for env-only credentials and the multi-account refusal; connections integration case for provider gating and the Facebook dialog redirect. - docs/oauth-connect.md: Instagram setup, callback URI and limits. Not in this change: publishing images needs a public plaintext JPEG URL that Meta downloads, and storage is client-side encrypted, so presigned S3 URLs cannot serve it. Until a short-lived media endpoint exists, publishing to Instagram fails with an explicit error. Token refresh via ig_refresh_token on graph.facebook.com also needs checking against Facebook Login tokens. Co-Authored-By: Claude Opus 5.5 --- apps/api/package.json | 1 + .../connections.integration.test.ts | 27 ++++++- .../api/src/connections/connector-registry.ts | 3 + apps/api/src/publishing/publishing.service.ts | 3 + docs/oauth-connect.md | 11 ++- .../connector-instagram/src/contract.test.ts | 75 +++++++++++------ packages/connector-instagram/src/index.ts | 80 +++++++------------ packages/connector-runtime/src/policy.ts | 2 + pnpm-lock.yaml | 3 + 9 files changed, 127 insertions(+), 78 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 2fe9eef..b6765eb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tactical-media-broadcast-hub/connector-sdk": "workspace:*", "@tactical-media-broadcast-hub/connector-telegram": "workspace:*", + "@tactical-media-broadcast-hub/connector-instagram": "workspace:*", "@tactical-media-broadcast-hub/core": "workspace:*", "@aws-sdk/client-s3": "^3.1146.0", "@aws-sdk/s3-request-presigner": "^3.1146.0", diff --git a/apps/api/src/connections/connections.integration.test.ts b/apps/api/src/connections/connections.integration.test.ts index f3b999d..8d615d4 100644 --- a/apps/api/src/connections/connections.integration.test.ts +++ b/apps/api/src/connections/connections.integration.test.ts @@ -9,6 +9,8 @@ import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk"; import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js"; import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram"; import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js"; +import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram"; +import { connectionRegistry } from "./connector-registry.js"; import { Vault, type SealedCredential } from "../vault/vault.js"; import type { SessionPayload } from "../auth/session.js"; import { RbacGuard } from "../rbac/rbac.guard.js"; @@ -105,12 +107,13 @@ describe("HTTP OAuth connect with real connectors and mock authorization server" let issued = 0; let failExchange = false; let bridgeConfigured = false; + let instagramConfigured = false; const botToken = "12345:bot_token_secret"; beforeEach(() => { store.rows.clear(); store.audit.length = 0; store.authorized = true; redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0; - nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; + nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; instagramConfigured = false; warn.mockClear(); info.mockClear(); }); @@ -180,6 +183,9 @@ describe("HTTP OAuth connect with real connectors and mock authorization server" })); const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init); registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 })); + registry.register(createInstagramConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000, + env: instagramConfigured ? { INSTAGRAM_CLIENT_ID: "hub-app-id", INSTAGRAM_CLIENT_SECRET: "hub-app-secret" } : {}, + })); return registry; }, }; @@ -342,6 +348,25 @@ describe("HTTP OAuth connect with real connectors and mock authorization server" bridgeConfigured = false; }); + it("offers Instagram only with the Hub's Facebook app configured, and keeps the app secret out of the browser", async () => { + expect(connectionRegistry({} as never).has("instagram")).toBe(true); + const providersOf = async () => await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>; + expect((await providersOf()).find((provider) => provider.id === "instagram")).toMatchObject({ enabled: false, note: expect.stringContaining("INSTAGRAM_CLIENT_ID") }); + expect((await api("/authorize", "POST", { provider: "instagram" })).status).toBe(400); + instagramConfigured = true; + expect((await providersOf()).find((provider) => provider.id === "instagram")?.enabled).toBe(true); + const response = await api("/authorize", "POST", { provider: "instagram" }); + expect(response.status).toBe(201); + const { redirectUrl } = await response.json() as { redirectUrl: string }; + const dialog = new URL(redirectUrl); + expect(dialog.origin + dialog.pathname).toBe("https://www.facebook.com/v21.0/dialog/oauth"); + expect(dialog.searchParams.get("client_id")).toBe("hub-app-id"); + expect(dialog.searchParams.get("redirect_uri")).toBe(`${apiOrigin}/connections/callback`); + expect(dialog.searchParams.get("state")).toMatch(/.{32,}/); + expect(redirectUrl).not.toContain("hub-app-secret"); + instagramConfigured = false; + }); + it("sets, validates and clears per-connection publish governance", async () => { await finish(await begin()); const id = (await listed())[0]?.id ?? ""; diff --git a/apps/api/src/connections/connector-registry.ts b/apps/api/src/connections/connector-registry.ts index 8454727..e3e5b18 100644 --- a/apps/api/src/connections/connector-registry.ts +++ b/apps/api/src/connections/connector-registry.ts @@ -1,5 +1,7 @@ import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk"; // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. +import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram"; +// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon"; // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js"; @@ -13,5 +15,6 @@ export function connectionRegistry(credentials: CredentialAccessor, fetchImpl: t registry.register(createMastodonConnector(options)); registry.register(createPeerTubeConnector(options)); registry.register(createTelegramConnector(options)); + registry.register(createInstagramConnector(options)); return registry; } diff --git a/apps/api/src/publishing/publishing.service.ts b/apps/api/src/publishing/publishing.service.ts index f32d8c1..278edac 100644 --- a/apps/api/src/publishing/publishing.service.ts +++ b/apps/api/src/publishing/publishing.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common"; // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. +import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram"; +// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon"; import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime"; import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk"; @@ -129,6 +131,7 @@ function connectorFor(provider: string): PlatformConnector { }; registry.register(createMastodonConnector(options)); registry.register(createTelegramConnector(options)); + registry.register(createInstagramConnector(options)); return registry; })(); return publishingConnectors.get(provider); diff --git a/docs/oauth-connect.md b/docs/oauth-connect.md index c84a1ac..8f6b9cf 100644 --- a/docs/oauth-connect.md +++ b/docs/oauth-connect.md @@ -1,6 +1,6 @@ # Connecting platform accounts -The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, and Telegram. Other providers are not offered as connectable until a connector implements the same authorization surface. +The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, Telegram, and Instagram. Other providers are not offered as connectable until a connector implements the same authorization surface. ## Configuration @@ -16,6 +16,7 @@ The API resolves authorization behavior through connector methods and manifests. | `MASTODON_CLIENT_ID`, `MASTODON_CLIENT_SECRET` | Optional pre-registered Mastodon application credentials, used only for `MASTODON_INSTANCE_URL`. | | `PEERTUBE_INSTANCE_URL` | Instance origin to which the configured PeerTube bridge client belongs. | | `PEERTUBE_CLIENT_ID`, `PEERTUBE_CLIENT_SECRET` | Optional credentials for that instance. Otherwise the connector discovers the local client through `/api/v1/oauth-clients/local`. | +| `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET` | The Hub's Facebook app (App ID / App Secret from developers.facebook.com → App settings → Basic). One pair per deployment; Instagram is offered as connectable only when both are set. | | `PEERTUBE_AUTHORIZE_PATH`, `PEERTUBE_TOKEN_PATH` | Required to enable a PeerTube redirect bridge. Both must be paths beginning with `/`, without a second leading slash, on the selected instance origin. | Use HTTPS and the same browser site for the web and API (for example `app.example.org` and `api.example.org`), or proxy the API under the web origin. The callback binding cookie is HttpOnly, SameSite=Lax, and Secure in production. For development, consistently use `localhost` or consistently use `127.0.0.1` for both web and API; mixing the two breaks browser cookie delivery. @@ -30,6 +31,14 @@ Stock PeerTube documents local OAuth client discovery followed by a **password g An instance administrator can enable a compatible OAuth bridge, then configure the two paths above. Its authorize endpoint must preserve `state`, register/validate the redirect URI, support `response_type=code` and S256 PKCE, and redirect back with a one-use code. Its token endpoint must validate `client_id`, `client_secret`, `redirect_uri`, `code`, and `code_verifier`, returning **actual PeerTube API** `access_token` and `refresh_token` values. Identity, refresh, and revoke then use PeerTube's native `/api/v1/users/me`, `/api/v1/users/token`, and `/api/v1/users/revoke-token` endpoints. These settings do not add an authorization-code grant to a stock instance, and an OIDC identity token alone is insufficient. The mock integration test proves this adapter contract; no third-party bridge is included in this change. +### Instagram + +Instagram connects through Facebook Login against the Hub's own Facebook app. The App ID and App Secret identify the Hub, not a collective: they are read only from `INSTAGRAM_CLIENT_ID` / `INSTAGRAM_CLIENT_SECRET`, never entered on the connect form and never written to the vault or the pending store. In the Facebook app, add the product *Facebook Login for Business* and register the exact `CONNECTIONS_CALLBACK_URL` (default `${API_URL}/connections/callback`) as a Valid OAuth Redirect URI. + +Each person connects their own account through Meta's consent dialog; the Hub keeps the resulting long-lived token encrypted per connection. Requirements per account: an Instagram **Business or Creator** account linked to a Facebook Page, and — while the app is in Development mode — a Tester role on the app. Grant exactly one Instagram account in the dialog: when several are granted the connect fails instead of guessing which one to publish as. Public use needs Meta App Review for `instagram_basic`, `instagram_content_publish` and `pages_show_list`. + +Limitations: Instagram has no text-only posts and no binary upload — Meta downloads each JPEG from a public https URL. Media storage is client-side encrypted, so S3 presigned URLs cannot serve it; publishing images requires a short-lived plaintext media endpoint that is not part of this change. Until then publishing to Instagram fails with an explicit error. Stories are not publishable through the API; Meta caps API publishing at 25 posts per rolling 24 h per account. + ### Telegram Create a bot in BotFather and enter its bot token. The connector validates it with `getMe`, then stores the token through the same encrypted vault envelope as OAuth credentials. Optionally supply a chat/channel ID (`@channel` or a numeric chat ID); add the bot to the target and grant any necessary posting rights. Identity health confirms token validity, not permission to publish to every chat. Reconnect accepts a replacement bot token for the same bot and rotates the credential row in place. Telegram's Bot API cannot revoke its own bot token: Remove clears local authorization; revoke/rotate the token through BotFather when needed. diff --git a/packages/connector-instagram/src/contract.test.ts b/packages/connector-instagram/src/contract.test.ts index 7f656b0..4892fc6 100644 --- a/packages/connector-instagram/src/contract.test.ts +++ b/packages/connector-instagram/src/contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core"; -import { AuthError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk"; +import { InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk"; import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing"; import { AuthorizationRedirectRequired, @@ -19,7 +19,6 @@ interface StubCall { init: RequestInit | undefined; } -const API = "https://graph.facebook.com/v21.0"; const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth"; const REDIRECT = "https://app.example.com/oauth/instagram/callback"; const IG_USER_ID = "17841400000000001"; @@ -169,11 +168,12 @@ interface Harness { credentials: InMemoryCredentials; } +/** The Hub's Facebook app lives in the API environment, never in a connection. */ +const APP_ENV = { INSTAGRAM_CLIENT_ID: "stub-client-id", INSTAGRAM_CLIENT_SECRET: "stub-client-secret" }; + async function createHarness(seedToken = true): Promise { const stub = { calls: [], nextId: 100 }; const credentials = new InMemoryCredentials(); - await credentials.put(connection, "client_id", "stub-client-id"); - await credentials.put(connection, "client_secret", "stub-client-secret"); await credentials.put(connection, "ig_user_id", IG_USER_ID); if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN); const connector = createInstagramConnector({ @@ -181,7 +181,7 @@ async function createHarness(seedToken = true): Promise { requestsPerSecond: 1000, sleep: async () => Promise.resolve(), credentials, - env: {}, + env: APP_ENV, }); return { stub, connector, credentials }; } @@ -190,8 +190,6 @@ const leg1: InstagramAuthorizationRequest = { redirectUri: REDIRECT, workspaceId: "ws-1", state: "csrf-1", - // App credentials arrive as connection fields on the connect form. - fields: { clientId: "stub-client-id", clientSecret: "stub-client-secret" }, }; const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record = {}): NormalizedPost => ({ @@ -250,9 +248,9 @@ describe("instagram connector: authorize + identity", () => { expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list"); expect(dialog.searchParams.get("state")).toBe("csrf-1"); expect(redirect.connectionId).toBe("instagram:ws-1"); - // App credentials persist for leg 2. - expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id"); - expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret"); + // App credentials stay in the environment: nothing app-level reaches the vault. + await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).rejects.toThrow(); + await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).rejects.toThrow(); expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0); }); @@ -299,7 +297,7 @@ describe("instagram connector: authorize + identity", () => { }); it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => { - const fetchImpl: typeof fetch = async (input, init) => { + const fetchImpl: typeof fetch = async (input) => { const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const url = new URL(href); url.pathname = url.pathname.replace(/^\/v21\.0/, ""); @@ -312,20 +310,56 @@ describe("instagram connector: authorize + identity", () => { } return graphError("no route", 100, 404); }; - const credentials = new InMemoryCredentials(); - await credentials.put(connection, "client_id", "stub-client-id"); - await credentials.put(connection, "client_secret", "stub-client-secret"); const connector = createInstagramConnector({ fetchImpl, requestsPerSecond: 1000, sleep: async () => undefined, - credentials, - env: {}, + credentials: new InMemoryCredentials(), + env: APP_ENV, }); const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } }; await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/); }); + it("refuses to pick one when several Instagram accounts were granted", async () => { + const fetchImpl: typeof fetch = async (input) => { + const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(href); + url.pathname = url.pathname.replace(/^\/v21\.0/, ""); + if (url.pathname === "/oauth/access_token") { + return json({ access_token: url.searchParams.get("code") ? SHORT_TOKEN : LONG_TOKEN, token_type: "bearer" }); + } + if (url.pathname === "/me") return json({ id: FB_USER_ID }); + if (url.pathname === "/me/accounts") { + return json({ + data: [ + { id: "page-a", instagram_business_account: { id: "17841400000000001", username: "acampada_a" } }, + { id: "page-b", instagram_business_account: { id: "17841400000000002", username: "acampada_b" } }, + ], + }); + } + return graphError("no route", 100, 404); + }; + const connector = createInstagramConnector({ + fetchImpl, + requestsPerSecond: 1000, + sleep: async () => undefined, + credentials: new InMemoryCredentials(), + env: APP_ENV, + }); + const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } }; + await expect(connector.authorize(request)).rejects.toThrow(/2 Instagram professional accounts were granted/); + }); + + it("is not connectable until the Hub's Facebook app is configured", async () => { + const connector = createInstagramConnector({ credentials: new InMemoryCredentials(), env: {} }); + expect(connector.manifest().oauth?.connectEnabled).toBe(false); + expect(connector.manifest().connectionFields.map((field) => field.name)).toEqual(["igUserId"]); + await expect(connector.authorize(leg1)).rejects.toThrow(/INSTAGRAM_CLIENT_ID/); + const configured = createInstagramConnector({ credentials: new InMemoryCredentials(), env: APP_ENV }); + expect(configured.manifest().oauth?.connectEnabled).toBe(true); + }); + it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => { const { stub, connector } = await createHarness(); const identity = await connector.getIdentity(connection); @@ -374,8 +408,6 @@ describe("instagram connector: publish", () => { it("uploads assets through loadAsset and captions the container", async () => { const stub = { calls: [] as StubCall[], nextId: 100 }; const credentials = new InMemoryCredentials(); - await credentials.put(connection, "client_id", "stub-client-id"); - await credentials.put(connection, "client_secret", "stub-client-secret"); await credentials.put(connection, "ig_user_id", IG_USER_ID); await credentials.put(connection, "access_token", LONG_TOKEN); const connector = createInstagramConnector({ @@ -415,8 +447,6 @@ describe("instagram connector: publish", () => { const stub = { calls: [] as StubCall[], nextId: 100 }; const credentials = new InMemoryCredentials(); for (const [key, value] of [ - ["client_id", "stub-client-id"], - ["client_secret", "stub-client-secret"], ["ig_user_id", IG_USER_ID], ["access_token", LONG_TOKEN], ] as const) { @@ -458,8 +488,6 @@ describe("instagram connector: publish", () => { const stub = { calls: [] as StubCall[], nextId: 100 }; const credentials = new InMemoryCredentials(); for (const [key, value] of [ - ["client_id", "stub-client-id"], - ["client_secret", "stub-client-secret"], ["ig_user_id", IG_USER_ID], ["access_token", LONG_TOKEN], ] as const) { @@ -499,7 +527,6 @@ describe("instagram connector: publish", () => { it("rejects non-JPEG assets", async () => { const { connector } = await createHarness(); - (connector as unknown as { options?: unknown }); await expect( connector.publish?.(connection, post("png", ["asset-png"], { media: [], @@ -538,8 +565,6 @@ describe("instagram connector: deletePublication", () => { const stub404Calls: StubCall[] = []; const credentials = new InMemoryCredentials(); for (const [key, value] of [ - ["client_id", "stub-client-id"], - ["client_secret", "stub-client-secret"], ["ig_user_id", IG_USER_ID], ["access_token", LONG_TOKEN], ] as const) { diff --git a/packages/connector-instagram/src/index.ts b/packages/connector-instagram/src/index.ts index c4144b9..db3ff70 100644 --- a/packages/connector-instagram/src/index.ts +++ b/packages/connector-instagram/src/index.ts @@ -10,7 +10,9 @@ * immediately exchanges it (grant_type=fb_exchange_token) for a long-lived * (~60 day) token, then resolves the Instagram professional account through * GET /me + GET /me/accounts (instagram_business_account). Stored credential - * keys: access_token, ig_user_id, client_id, client_secret. + * keys: access_token, ig_user_id. The Facebook app credentials identify the + * Hub, not a collective: they come only from INSTAGRAM_CLIENT_ID / + * INSTAGRAM_CLIENT_SECRET in the API environment and never enter the vault. * * Honest capability notes (verified against the Graph API): * - Instagram has no text-only posts. publishText is declared only because @@ -100,8 +102,6 @@ const CAPABILITIES: ConnectorCapabilities = { const SECRET = { accessToken: "access_token", igUserId: "ig_user_id", - clientId: "client_id", - clientSecret: "client_secret", } as const; export interface InstagramConnectorOptions { @@ -115,7 +115,7 @@ export interface InstagramConnectorOptions { credentials?: CredentialAccessor; /** Graph API version path segment. Default v21.0. */ apiVersion?: string; - /** Facebook app credentials fallback when not supplied per connection. */ + /** INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET: the Hub's Facebook app. */ env?: Record; /** Resolve a media asset id to its metadata (needed by publish with media). */ loadAsset?: (assetId: string) => Promise; @@ -270,18 +270,6 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} displayName: "Instagram", capabilities: CAPABILITIES, connectionFields: [ - { - name: "clientId", - label: "Facebook app ID (or INSTAGRAM_CLIENT_ID env)", - required: false, - secret: false, - }, - { - name: "clientSecret", - label: "Facebook app secret (or INSTAGRAM_CLIENT_SECRET env)", - required: false, - secret: true, - }, { name: "igUserId", label: "Instagram professional account id (optional — resolved at connect)", @@ -296,14 +284,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} authorizeUrlTemplate: `${dialogBase}/dialog/oauth`, scopes: SCOPES, pkce: false, + connectEnabled: Boolean(str(env.INSTAGRAM_CLIENT_ID) && str(env.INSTAGRAM_CLIENT_SECRET)), + connectNote: "Instagram linking needs the Hub's Facebook app: set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET on the API.", }, docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing", }; - function igUserIdOf(connection: ConnectorConnection): string { - return str(connection.fields.igUserId) ?? `vault:${connection.id}`; - } - /** Vault first; the connection field is the dev/test fallback. */ async function tokenOf(connection: ConnectorConnection): Promise { try { @@ -333,24 +319,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} } } - /** Facebook app credentials: vault first, then connection fields, then env. */ - void secretOrUndefined; - async function clientCredentials( - connection: ConnectorConnection, - ): Promise<{ clientId: string; clientSecret: string }> { - const clientId = - (await secretOrUndefined(connection, SECRET.clientId)) ?? - str(connection.fields.clientId) ?? - str(env.INSTAGRAM_CLIENT_ID); - const clientSecret = - (await secretOrUndefined(connection, SECRET.clientSecret)) ?? - str(connection.fields.clientSecret) ?? - str(env.INSTAGRAM_CLIENT_SECRET); + /** The Hub's Facebook app credentials — server environment only, never per connection. */ + function clientCredentials(): { clientId: string; clientSecret: string } { + const clientId = str(env.INSTAGRAM_CLIENT_ID); + const clientSecret = str(env.INSTAGRAM_CLIENT_SECRET); if (clientId === undefined || clientSecret === undefined) { - throw new ConnectorError( - `${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, - PROVIDER, - ); + throw new ConnectorError(`${PROVIDER}: no Facebook app credentials — set INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, PROVIDER); } return { clientId, clientSecret }; } @@ -408,16 +382,27 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} /** * GET /me validates the token (it is the Facebook user under Facebook - * Login), then GET /me/accounts resolves the first Page carrying an + * Login), then GET /me/accounts resolves the Page carrying an * instagram_business_account — the IG professional account to publish as. + * Exactly one must be granted: picking one of several would publish as an + * account nobody chose, so the person re-links selecting a single one in + * Meta's dialog. */ async function fetchIdentity(connection: ConnectorConnection): Promise { const me = await graph(connection, "GET", "/me", { fields: "id,name" }); const { data } = await graph>(connection, "GET", "/me/accounts", { fields: PAGE_FIELDS, }); - const linked = data.find((page) => page.instagram_business_account !== undefined) - ?.instagram_business_account; + const granted = data + .map((page) => page.instagram_business_account) + .filter((account): account is NonNullable => account !== undefined); + if (granted.length > 1) { + throw new ConnectorError( + `${PROVIDER}: ${granted.length} Instagram professional accounts were granted — select exactly one in the Facebook dialog`, + PROVIDER, + ); + } + const linked = granted[0]; if (linked === undefined) { throw new ConnectorError( `${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`, @@ -516,7 +501,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} provider: PROVIDER, channelId: error.connectionId, credentialId: { kind: "credential-ref", id: error.connectionId }, - fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {}, + fields: {}, createdAt: new Date(), }; return { authorizationUrl: error.authorizationUrl, connection }; @@ -531,7 +516,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} }, /** - * Leg 1 (no code): validate app credentials, store them in the vault and + * Leg 1 (no code): read the Hub's app credentials from the environment and * throw AuthorizationRedirectRequired with the Facebook dialog URL. * Leg 2 (providerMetadata.code): code → short-lived token → long-lived * token (fb_exchange_token) → resolve the IG professional account via @@ -542,24 +527,17 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} const code = str(meta.code); const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`; const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId }; - const provided: Record = {}; - const fieldClientId = str(input.fields?.clientId); - const fieldClientSecret = str(input.fields?.clientSecret); - if (fieldClientId !== undefined) provided.clientId = fieldClientId; - if (fieldClientSecret !== undefined) provided.clientSecret = fieldClientSecret; const connection: ConnectorConnection = { id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: str(meta.channelId) ?? connectionId, credentialId, - fields: { ...provided }, + fields: {}, createdAt: new Date(), }; - const client = await clientCredentials({ ...connection, fields: provided }); - await credentials.put(connection, SECRET.clientId, client.clientId); - await credentials.put(connection, SECRET.clientSecret, client.clientSecret); + const client = clientCredentials(); if (code === undefined) { const url = new URL(`${dialogBase}/dialog/oauth`); diff --git a/packages/connector-runtime/src/policy.ts b/packages/connector-runtime/src/policy.ts index 0dc23b1..e5d9c4a 100644 --- a/packages/connector-runtime/src/policy.ts +++ b/packages/connector-runtime/src/policy.ts @@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000; export const PUBLISHING_POLICIES: Readonly> = { mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS }, telegram: { idempotency: "none" }, + // Graph media_publish takes no idempotency key: an ambiguous send is never replayed. + instagram: { idempotency: "none" }, }; /** Unknown providers get the safe default: never replay an ambiguous send. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d698cfe..b9e0838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@opentelemetry/sdk-node': specifier: ^0.222.0 version: 0.222.0(@opentelemetry/api@1.9.1) + '@tactical-media-broadcast-hub/connector-instagram': + specifier: workspace:* + version: link:../../packages/connector-instagram '@tactical-media-broadcast-hub/connector-mastodon': specifier: workspace:* version: link:../../packages/connector-mastodon From d310385d2930b3b8597412e76d54ff2ea4b41c65 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20outreach=20contacts=20=E2=80=94?= =?UTF-8?q?=20track=20hand-written=20DMs=20per=20collective?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/migrations/041_outreach.sql | 51 +++ apps/api/src/app.module.ts | 3 +- apps/api/src/outreach/outreach.controller.ts | 141 ++++++++ apps/api/src/outreach/outreach.module.ts | 13 + apps/api/src/outreach/outreach.service.ts | 229 +++++++++++++ apps/api/src/outreach/outreach.test.ts | 136 ++++++++ apps/web/package.json | 3 +- apps/web/src/app/(main)/layout.tsx | 2 +- apps/web/src/app/(main)/more/page.tsx | 1 + apps/web/src/app/(main)/outreach/page.tsx | 325 +++++++++++++++++++ apps/web/src/components/shell/sidebar.tsx | 1 + apps/web/src/lib/outreach-types.test.ts | 30 ++ apps/web/src/lib/outreach-types.ts | 93 ++++++ 13 files changed, 1025 insertions(+), 3 deletions(-) create mode 100644 apps/api/migrations/041_outreach.sql create mode 100644 apps/api/src/outreach/outreach.controller.ts create mode 100644 apps/api/src/outreach/outreach.module.ts create mode 100644 apps/api/src/outreach/outreach.service.ts create mode 100644 apps/api/src/outreach/outreach.test.ts create mode 100644 apps/web/src/app/(main)/outreach/page.tsx create mode 100644 apps/web/src/lib/outreach-types.test.ts create mode 100644 apps/web/src/lib/outreach-types.ts diff --git a/apps/api/migrations/041_outreach.sql b/apps/api/migrations/041_outreach.sql new file mode 100644 index 0000000..b454a62 --- /dev/null +++ b/apps/api/migrations/041_outreach.sql @@ -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; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ee1dd29..1e0e693 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/outreach/outreach.controller.ts b/apps/api/src/outreach/outreach.controller.ts new file mode 100644 index 0000000..7a0adb8 --- /dev/null +++ b/apps/api/src/outreach/outreach.controller.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/outreach/outreach.module.ts b/apps/api/src/outreach/outreach.module.ts new file mode 100644 index 0000000..fd0d6f0 --- /dev/null +++ b/apps/api/src/outreach/outreach.module.ts @@ -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 {} diff --git a/apps/api/src/outreach/outreach.service.ts b/apps/api/src/outreach/outreach.service.ts new file mode 100644 index 0000000..89ac18e --- /dev/null +++ b/apps/api/src/outreach/outreach.service.ts @@ -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 { + 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, + }; +} diff --git a/apps/api/src/outreach/outreach.test.ts b/apps/api/src/outreach/outreach.test.ts new file mode 100644 index 0000000..a1ef69d --- /dev/null +++ b/apps/api/src/outreach/outreach.test.ts @@ -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 = {}) => ({ + 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) => + 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(); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 950a9ec..52b1cf8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:^", diff --git a/apps/web/src/app/(main)/layout.tsx b/apps/web/src/app/(main)/layout.tsx index 2ab0dfb..28f48e0 100644 --- a/apps/web/src/app/(main)/layout.tsx +++ b/apps/web/src/app/(main)/layout.tsx @@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile"; const titles: Record = { 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(); diff --git a/apps/web/src/app/(main)/more/page.tsx b/apps/web/src/app/(main)/more/page.tsx index 3f40f0e..39dc9bb 100644 --- a/apps/web/src/app/(main)/more/page.tsx +++ b/apps/web/src/app/(main)/more/page.tsx @@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {

Settings

+ diff --git a/apps/web/src/app/(main)/outreach/page.tsx b/apps/web/src/app/(main)/outreach/page.tsx new file mode 100644 index 0000000..1ec7cde --- /dev/null +++ b/apps/web/src/app/(main)/outreach/page.tsx @@ -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 = { + 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("/outreach/contacts", identity, SAMPLE); + const live = contacts.source === "live"; + const canManage = live && can("outreach.manage"); + const [filter, setFilter] = useState("todo"); + const [category, setCategory] = useState("all"); + const [openId, setOpenId] = useState(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(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; + 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>): Promise { + try { + replace(await api.patch(`/outreach/contacts/${id}`, body)); + } catch (err) { + setNotice(errorMessage(err)); + } + } + + async function copyAndOpen(contact: OutreachContact): Promise { + 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 { + 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 { + 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 ( +
+
+

Outreach

+ + {!live && read-only sample} +
+

+ 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. +

+ +
+ {(["all", ...OUTREACH_STATUSES] as const).map((status) => ( + + ))} + {categories.length > 0 && ( + + )} + + Sent today: = OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today} + +
+ + {today >= OUTREACH_DAILY_SOFT_CAP && ( + +

+ {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. +

+
+ )} + + {(notice !== null || contacts.error !== null) && ( + +

{contacts.error ?? notice}

+ {notice !== null && } +
+ )} + + {canManage && } + + {contacts.source === "loading" ? ( +
+ ) : visible.length === 0 ? ( + + ) : ( +
+ {visible.map((contact) => ( + setOpenId(openId === contact.id ? null : contact.id)} + onPatch={(body) => patch(contact.id, body)} + onCopyOpen={() => copyAndOpen(contact)} + onRemove={() => remove(contact)} + /> + ))} +
+ )} +
+ ); +} + +function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: { + contact: OutreachContact; + open: boolean; + canManage: boolean; + onToggle: () => void; + onPatch: (body: Partial>) => Promise; + onCopyOpen: () => Promise; + onRemove: () => Promise; +}): React.ReactElement { + const [message, setMessage] = useState(contact.message); + const [notes, setNotes] = useState(contact.notes); + + return ( + + + {open && ( +
+