From 0ff1484a6e46dc3bab983ce0906c838e7a6a6f94 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH 1/4] chore(db): force row-level security on media_assets Migration 042 enables and forces the tenant_isolation policy on media_assets with an explicit WITH CHECK; media and publishing queries also scope by app.workspace_id. --- apps/api/migrations/042_media_assets_rls.sql | 11 +++++++++++ apps/api/src/media/media.service.ts | 4 ++-- apps/api/src/publishing/publishing.service.ts | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 apps/api/migrations/042_media_assets_rls.sql diff --git a/apps/api/migrations/042_media_assets_rls.sql b/apps/api/migrations/042_media_assets_rls.sql new file mode 100644 index 0000000..1325a28 --- /dev/null +++ b/apps/api/migrations/042_media_assets_rls.sql @@ -0,0 +1,11 @@ +-- 042: media_assets row-level security. +-- +-- Enable and force the tenant_isolation policy declared in 038, with an +-- explicit WITH CHECK. All media_assets queries run inside withTenant. + +ALTER TABLE media_assets ENABLE ROW LEVEL SECURITY; +ALTER TABLE media_assets FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON media_assets; +CREATE POLICY tenant_isolation ON media_assets + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) + WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index 871d00d..31515a2 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -170,7 +170,7 @@ export class MediaService { /** Auth-scoped read: RLS already fences this to the caller's workspace. */ async readAsset(workspaceId: string, id: string): Promise { const asset = await withTenant(workspaceId, async (client) => { - const result = await client.query("SELECT * FROM media_assets WHERE id = $1", [id]); + const result = await client.query("SELECT * FROM media_assets WHERE id = $1 AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid", [id]); const row = result.rows[0]; if (row === undefined) throw new NotFoundException(`media asset ${id} not found`); return fromRow(row); @@ -192,7 +192,7 @@ export class MediaService { ): Promise { if (mediaAssetIds.length === 0) return; const result = await client.query( - "SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])", + "SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid", [mediaAssetIds], ); const count = Number(result.rows[0]?.count ?? 0); diff --git a/apps/api/src/publishing/publishing.service.ts b/apps/api/src/publishing/publishing.service.ts index f32d8c1..18ea0eb 100644 --- a/apps/api/src/publishing/publishing.service.ts +++ b/apps/api/src/publishing/publishing.service.ts @@ -654,7 +654,7 @@ export class PublishingService { async function assertMediaAssetsExist(client: PoolClient, mediaAssetIds: string[]): Promise { if (mediaAssetIds.length === 0) return; const result = await client.query<{ count: string | number }>( - "SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])", + "SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid", [mediaAssetIds], ); if (Number(result.rows[0]?.count ?? 0) !== mediaAssetIds.length) { From 4f0b69f5aa135786382fc6401f0ff6cf5196035f Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 15:09:07 +0200 Subject: [PATCH 2/4] fix(video): logo overlays must belong to the workspace; drop debug stderr - produceVideo checks every logo overlay key against the workspace's media_assets, group_assets or branding logo before queueing; a well-formed content address alone no longer suffices. - Remove MEDIA_DEBUG_STDERR=1 from the production compose: it wrote raw ffmpeg stderr to an unrotated file, bypassing redaction. --- apps/api/src/video/test-fixtures.ts | 5 +++++ apps/api/src/video/video.service.test.ts | 13 +++++++++++++ apps/api/src/video/video.service.ts | 23 +++++++++++++++++++++++ infra/occupymedia/docker-compose.app.yml | 1 - 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/api/src/video/test-fixtures.ts b/apps/api/src/video/test-fixtures.ts index 8755a67..a498025 100644 --- a/apps/api/src/video/test-fixtures.ts +++ b/apps/api/src/video/test-fixtures.ts @@ -16,6 +16,8 @@ export interface FakeVideoDatabase { transitions?: string[]; /** Raw INSERT statements seen, for asserting what intake wrote. */ statements?: Array<{ sql: string; values: readonly unknown[] }>; + /** Image storage keys this workspace holds (logo overlay ownership). */ + ownedImageKeys?: string[]; } function result(rows: Array>): QueryResult> { @@ -89,6 +91,9 @@ export function serviceWithDatabase( if (sql === "SELECT * FROM video_assets WHERE id = $1") { return result(database.asset === undefined ? [] : [database.asset]); } + if (sql.includes("branding->>'logoAssetKey'")) { + return result(database.ownedImageKeys?.includes(String(values[0])) === true ? [{}] : []); + } if (sql.includes("SET state = 'probing'")) { if (database.job?.state !== "queued") return result([]); database.job.state = "probing"; diff --git a/apps/api/src/video/video.service.test.ts b/apps/api/src/video/video.service.test.ts index 30d9716..d513cca 100644 --- a/apps/api/src/video/video.service.test.ts +++ b/apps/api/src/video/video.service.test.ts @@ -148,6 +148,19 @@ describe("video byte ingest and processing-job state machine", () => { }); }); + it("accepts a logo overlay only when the image belongs to the workspace", async () => { + const owned = `sha256/${"a".repeat(64)}.png`; + const foreign = `sha256/${"b".repeat(64)}.png`; + const database: FakeVideoDatabase = { ownedImageKeys: [owned] }; + const { service } = serviceWithDatabase(database, async () => ({})); + await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4"); + const logo = (key: string): ProduceRequest => ({ overlays: [{ kind: "logo", logoStorageKey: key, position: { x: 90, y: 5 } }] }); + await expect(service.produceVideo(assetId, workspaceId, logo(foreign), "u")).rejects.toMatchObject({ status: 400 }); + expect(database.statements?.some((s) => s.sql.includes("INSERT INTO video_processing_jobs") && String(s.values[3]).includes(foreign))).toBe(false); + const produced = await service.produceVideo(assetId, workspaceId, logo(owned), "u"); + expect(produced.job.state).toBe("queued"); + }); + it("refuses to produce from an unknown asset", async () => { const { service } = serviceWithDatabase({}, async () => ({})); await expect(service.produceVideo("30000000-0000-4000-8000-000000000005", workspaceId, {}, "u")).rejects.toMatchObject({ status: 404 }); diff --git a/apps/api/src/video/video.service.ts b/apps/api/src/video/video.service.ts index 4a3dfb0..0c8d00c 100644 --- a/apps/api/src/video/video.service.ts +++ b/apps/api/src/video/video.service.ts @@ -544,6 +544,7 @@ export class VideoService { const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => { const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]); if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`); + await assertLogoKeysOwned(client, workspaceId, request); const jobResult = await client.query( `INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json) VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`, @@ -774,3 +775,25 @@ function decodeCursor(cursor: string): VideoCursor { function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } + +/** + * Storage keys are content-addressed in one shared bucket, so a well-formed key + * alone proves nothing about who owns the object. A logo overlay may only use + * an image this workspace holds: an attachment, a group asset or its branding. + */ +async function assertLogoKeysOwned( + client: { query: (sql: string, values: unknown[]) => Promise<{ rowCount: number | null }> }, + workspaceId: string, + request: ProduceRequest, +): Promise { + const keys = [...new Set((request.overlays ?? []).flatMap((overlay) => (overlay.logoStorageKey === undefined ? [] : [overlay.logoStorageKey])))]; + for (const key of keys) { + const owned = await client.query( + `SELECT 1 WHERE EXISTS (SELECT 1 FROM media_assets WHERE storage_key = $1 AND workspace_id = $2) + OR EXISTS (SELECT 1 FROM group_assets WHERE asset_id = $1 AND workspace_id = $2) + OR EXISTS (SELECT 1 FROM workspaces WHERE id = $2 AND branding->>'logoAssetKey' = $1)`, + [key, workspaceId], + ); + if (owned.rowCount !== 1) throw new BadRequestException("logoStorageKey is not an image of this collective"); + } +} diff --git a/infra/occupymedia/docker-compose.app.yml b/infra/occupymedia/docker-compose.app.yml index c14bca7..9b34911 100644 --- a/infra/occupymedia/docker-compose.app.yml +++ b/infra/occupymedia/docker-compose.app.yml @@ -183,7 +183,6 @@ services: <<: *appenv MEDIA_WORKER_LANE: local MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce - MEDIA_DEBUG_STDERR: "1" volumes: [mediaworkerstate:/var/lib/media-worker] depends_on: postgres: { condition: service_healthy } From 57762f543c45c1ad778d3e941acb85bc2f307319 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH 3/4] 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. --- 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 18ea0eb..2b7bc01 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 1db3d4b..8b4bac2 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 8f01765df39bb89bcc0c36592cff307fb2443eb0 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20adapt=20T-HUB=20features=20?= =?UTF-8?q?=E2=80=94=20folders=20schema,=20Instagram=20connector,=20outrea?= =?UTF-8?q?ch=20contacts,=20media=20RLS=20force?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the hacklab/T-HUB fork (diverged at a6843e9): - 046_file_folders: folder trees (group + personal scope, RLS forced, workspace-immutability trigger) + created_by on chat_media_archive - Instagram connector wired into connect + publishing - outreach contacts: per-collective DM tracking (todo/sent/replied/declined), RLS forced, role-scoped permissions, audit without message text, migration renumbered 041→047 - FORCE row-level security on media_assets The J1 api agent's folder service/controller work continues separately. --- apps/api/migrations/047_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 +++ .../scripts/install-windows-worker.ps1 | 61 ++ .../scripts/test-windows-worker.md | 116 +++ .../scripts/windows-transcode-worker.py | 185 +++++ apps/web/package.json | 3 +- .../src/app/(files-host)/files-app/page.tsx | 9 + apps/web/src/app/(files-host)/layout.tsx | 22 + 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/app/page.tsx | 8 +- .../files/explorer/details-drawer.tsx | 155 ++++ .../components/files/explorer/explorer-api.ts | 161 ++++ .../files/explorer/explorer-dialogs.tsx | 237 ++++++ .../files/explorer/files-explorer.tsx | 786 ++++++++++++++++++ .../components/files/explorer/folder-tree.tsx | 243 ++++++ apps/web/src/components/shell/sidebar.tsx | 21 + apps/web/src/lib/outreach-types.test.ts | 30 + apps/web/src/lib/outreach-types.ts | 93 +++ apps/web/src/middleware.ts | 27 + 25 files changed, 3054 insertions(+), 4 deletions(-) create mode 100644 apps/api/migrations/047_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/media-worker/scripts/install-windows-worker.ps1 create mode 100644 apps/media-worker/scripts/test-windows-worker.md create mode 100644 apps/media-worker/scripts/windows-transcode-worker.py create mode 100644 apps/web/src/app/(files-host)/files-app/page.tsx create mode 100644 apps/web/src/app/(files-host)/layout.tsx create mode 100644 apps/web/src/app/(main)/outreach/page.tsx create mode 100644 apps/web/src/components/files/explorer/details-drawer.tsx create mode 100644 apps/web/src/components/files/explorer/explorer-api.ts create mode 100644 apps/web/src/components/files/explorer/explorer-dialogs.tsx create mode 100644 apps/web/src/components/files/explorer/files-explorer.tsx create mode 100644 apps/web/src/components/files/explorer/folder-tree.tsx create mode 100644 apps/web/src/lib/outreach-types.test.ts create mode 100644 apps/web/src/lib/outreach-types.ts create mode 100644 apps/web/src/middleware.ts diff --git a/apps/api/migrations/047_outreach.sql b/apps/api/migrations/047_outreach.sql new file mode 100644 index 0000000..b454a62 --- /dev/null +++ b/apps/api/migrations/047_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 1ed834c..855f4a6 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -46,9 +46,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/media-worker/scripts/install-windows-worker.ps1 b/apps/media-worker/scripts/install-windows-worker.ps1 new file mode 100644 index 0000000..370516b --- /dev/null +++ b/apps/media-worker/scripts/install-windows-worker.ps1 @@ -0,0 +1,61 @@ +# Run in native Windows PowerShell as the user who will transcode on moxexleyhp1. +[CmdletBinding()] +param( + [string]$JobRoot = 'C:\transcode-jobs', + [string]$PythonExe +) + +$ErrorActionPreference = 'Stop' +if ($env:OS -ne 'Windows_NT') { + throw 'Run this installer on Windows directly, not inside WSL.' +} + +$WorkerScript = (Resolve-Path (Join-Path $PSScriptRoot 'windows-transcode-worker.py')).Path +if (-not $PythonExe) { + $PythonCommand = Get-Command python.exe -ErrorAction SilentlyContinue + if (-not $PythonCommand) { + $PythonCommand = Get-Command py.exe -ErrorAction SilentlyContinue + } + if (-not $PythonCommand) { + throw 'Install Python 3.9+ for Windows, then rerun with -PythonExe C:\path\to\python.exe.' + } + $PythonExe = $PythonCommand.Source +} +# Resolve py.exe (if used) to the real interpreter for the scheduled task. +$ResolvedPython = & $PythonExe -c 'import sys; assert sys.version_info >= (3, 9); print(sys.executable)' +if ($LASTEXITCODE -ne 0 -or -not $ResolvedPython) { + throw 'Could not run Python 3.9+. Pass -PythonExe with a working Windows interpreter.' +} +$PythonExe = ($ResolvedPython | Select-Object -Last 1).Trim() +$FfmpegExe = (Get-Command ffmpeg.exe -ErrorAction Stop).Source +$JobRoot = [System.IO.Path]::GetFullPath($JobRoot) +$Inbox = Join-Path $JobRoot 'inbox' +foreach ($Name in @('inbox', 'done', 'failed')) { + New-Item -ItemType Directory -Force -Path (Join-Path $JobRoot $Name) | Out-Null +} + +$TaskName = 'TMBH-TranscodeWorker' +$UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$Arguments = '-u "{0}" --inbox "{1}" --ffmpeg "{2}"' -f $WorkerScript, $Inbox, $FfmpegExe +$Action = New-ScheduledTaskAction -Execute $PythonExe -Argument $Arguments -WorkingDirectory $PSScriptRoot +$AtLogon = New-ScheduledTaskTrigger -AtLogOn -User $UserId +# No repetition duration means this trigger repeats indefinitely. +$EveryFiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5) +$Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited +$Settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + +Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger @($AtLogon, $EveryFiveMinutes) -Principal $Principal -Settings $Settings -Description 'Poll JSON jobs and transcode with native Windows NVENC or Intel QSV.' -Force | Out-Null +Start-ScheduledTask -TaskName $TaskName + +Write-Host "Installed and started: $TaskName (logon + every 5 minutes; duplicate starts ignored)." +Write-Host "Python: $PythonExe" +Write-Host "FFmpeg: $FfmpegExe" +Write-Host "Worker: $WorkerScript" +Write-Host "Inbox: $Inbox" +Write-Host "Done: $(Join-Path $JobRoot 'done'); failed: $(Join-Path $JobRoot 'failed') with .json.err diagnostics." +Write-Host 'Keep this Windows account logged on; the task uses its interactive GPU session.' +Write-Host 'Keep the repository at this location, or rerun this installer after moving it.' +Write-Host 'Publish jobs by writing a .tmp file, closing it, then renaming it to .json.' +Write-Host "Status: Get-ScheduledTask -TaskName '$TaskName'" +Write-Host "Stop: Stop-ScheduledTask -TaskName '$TaskName'" +Write-Host "Manual tests: $(Join-Path $PSScriptRoot 'test-windows-worker.md')" diff --git a/apps/media-worker/scripts/test-windows-worker.md b/apps/media-worker/scripts/test-windows-worker.md new file mode 100644 index 0000000..3c84fcc --- /dev/null +++ b/apps/media-worker/scripts/test-windows-worker.md @@ -0,0 +1,116 @@ +# Native Windows transcode worker: manual checks on moxexleyhp1 + +Run these commands in **Windows PowerShell**, using Windows Python 3.9+ and +the existing WinGet ffmpeg on PATH. Keep this Windows account logged on. + +## Install + +1. Open PowerShell in the Windows checkout of this repository. +2. Run: + + ```powershell + powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\apps\media-worker\scripts\install-windows-worker.ps1 + Get-ScheduledTask -TaskName 'TMBH-TranscodeWorker' + ``` + + If Python is not found, add `-PythonExe 'C:\path\to\python.exe'` to the + installer command. The optional `-JobRoot` changes all three queue folders. + The task starts immediately, polls every 5 seconds, starts at logon, and + is triggered every 5 minutes to recover if it exits. Duplicate starts are + ignored; a per-inbox file lock also prevents competing manual workers. + The installer can be rerun to update the task. + +3. Verify `inbox`, `done`, and `failed` exist under `C:\transcode-jobs`. + Generate a short input with video and audio: + + ```powershell + ffmpeg -hide_banner -n -f lavfi -i 'testsrc2=size=1280x720:rate=30' -f lavfi -i 'sine=frequency=440:sample_rate=48000' -t 10 -c:v mpeg4 -q:v 3 -c:a aac 'C:\transcode-jobs\sample.mp4' + if ($LASTEXITCODE -ne 0) { throw 'Sample generation failed' } + ``` + + Use fresh output/job names when repeating these checks. Existing output + files are rejected and preserved. Relative input/output paths resolve + against the inbox; absolute Windows paths are recommended. Outputs use + their extension to choose the container. Jobs run sequentially. + +## NVENC H.264 on the RTX 3090 + +- [ ] Publish a job atomically, so polling never reads a partly written JSON: + + ```powershell + @{ input = 'C:\transcode-jobs\sample.mp4'; output = 'C:\transcode-jobs\nvenc.mp4'; preset = 'nvenc_h264'; crf = 23 } | ConvertTo-Json | Set-Content -Encoding UTF8 'C:\transcode-jobs\inbox\nvenc.tmp' + Move-Item 'C:\transcode-jobs\inbox\nvenc.tmp' 'C:\transcode-jobs\inbox\nvenc.json' + ``` + +- [ ] Within a poll interval plus encode time, `done\nvenc.json` and a + playable, nonempty `C:\transcode-jobs\nvenc.mp4` exist; the inbox job is gone. +- [ ] Inspect the output: + + ```powershell + ffprobe -v error -show_entries stream=codec_name,codec_type -of json 'C:\transcode-jobs\nvenc.mp4' + ``` + + Expect H.264 video and AAC audio. While encoding, observe NVIDIA's **Video + Encode** graph in Task Manager (use a longer input if needed). +- [ ] Repeat with `preset = 'nvenc_hevc'`, a new job name, and output + `nvenc-hevc.mp4`; expect HEVC video and AAC audio. If the installed ffmpeg + lacks `hevc_nvenc`, expect a failed job with encoder diagnostics instead. + +## Intel QuickSync H.264 + +- [ ] Publish a QSV job, omitting `crf` to exercise the default quality of 23: + + ```powershell + @{ input = 'C:\transcode-jobs\sample.mp4'; output = 'C:\transcode-jobs\qsv.mp4'; preset = 'qsv_h264' } | ConvertTo-Json | Set-Content -Encoding UTF8 'C:\transcode-jobs\inbox\qsv.tmp' + Move-Item 'C:\transcode-jobs\inbox\qsv.tmp' 'C:\transcode-jobs\inbox\qsv.json' + ``` + +- [ ] Verify `done\qsv.json`, playable `qsv.mp4`, and H.264/AAC streams using + the ffprobe command above with the QSV output path. +- [ ] Observe the Intel GPU's **Video Encode** graph during the job. Encoder + initialization failures belong in `failed` and must include ffmpeg's error; + the worker never retries with a software video encoder. + +## Failure and continued polling + +- [ ] Queue a missing input: + + ```powershell + @{ input = 'C:\transcode-jobs\does-not-exist.mp4'; output = 'C:\transcode-jobs\must-not-exist.mp4'; preset = 'nvenc_h264' } | ConvertTo-Json | Set-Content -Encoding UTF8 'C:\transcode-jobs\inbox\missing.tmp' + Move-Item 'C:\transcode-jobs\inbox\missing.tmp' 'C:\transcode-jobs\inbox\missing.json' + ``` + +- [ ] Expect `failed\missing.json` and `failed\missing.json.err`, mentioning + the missing input. No output is created and the inbox job is gone. +- [ ] Queue a text file as input with a valid preset. Expect a failed JSON + and `.json.err` containing ffmpeg's nonzero exit code and stderr. No final + output or `.part.mp4` file should remain. +- [ ] Queue malformed JSON, an unknown preset, and a string `crf` in separate + jobs. Each must move to `failed` with a useful `.err` file. +- [ ] Queue an existing output path (e.g. the sample). Expect failure without + changing the existing file. +- [ ] Queue another valid job with a fresh output name; verify completion + after the failed jobs. Reuse a failed job filename and verify both archived + jobs and their errors survive (a UUID is added on name collisions). + +## Task lifecycle and foreground diagnostics + +- [ ] Log off and back on; verify the task runs. Stop the task, then verify + the repeating trigger starts it again within 5 minutes. +- [ ] For foreground diagnostics or a single pass, stop **and disable** the + task first so its repeating trigger cannot race the manual run: + + ```powershell + Disable-ScheduledTask -TaskName 'TMBH-TranscodeWorker' + Stop-ScheduledTask -TaskName 'TMBH-TranscodeWorker' + python .\apps\media-worker\scripts\windows-transcode-worker.py --once + # Exit status is 1 if any job in this pass failed; otherwise 0. + Enable-ScheduledTask -TaskName 'TMBH-TranscodeWorker' + Start-ScheduledTask -TaskName 'TMBH-TranscodeWorker' + ``` + + Omit `--once` for continuous foreground polling; press Ctrl+C to stop. + A second worker on the same inbox must exit without executing jobs. + Force-stopping during an encode can leave a hidden `.part` output; remove + it after confirming no ffmpeg process is using it. Its job stays in the + inbox for retry. Normal encode failures clean up the temporary output. diff --git a/apps/media-worker/scripts/windows-transcode-worker.py b/apps/media-worker/scripts/windows-transcode-worker.py new file mode 100644 index 0000000..adca954 --- /dev/null +++ b/apps/media-worker/scripts/windows-transcode-worker.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Native Windows hardware transcode worker; Python standard library only.""" + +import argparse +import json +import logging +import math +import os +from pathlib import Path +import subprocess +import tempfile +import time +import uuid + + +PRESETS = { + "nvenc_h264": ["-c:v", "h264_nvenc", "-preset", "p4", "-rc", "vbr"], + "qsv_h264": ["-c:v", "h264_qsv"], + "nvenc_hevc": ["-c:v", "hevc_nvenc", "-preset", "p4", "-rc", "vbr"], +} + + +class WorkerLock: + """Windows releases this byte-range lock even if the worker crashes.""" + + def __init__(self, inbox): + self.path = inbox / ".worker.lock" + self.file = None + + def __enter__(self): + import msvcrt + + self.file = self.path.open("a+b") + self.file.seek(0, os.SEEK_END) + if self.file.tell() == 0: + self.file.write(b"\0") + self.file.flush() + self.file.seek(0) + try: + msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + self.file.close() + self.file = None + raise + return self + + def __exit__(self, *args): + # Closing the handle releases the lock; keep the file to avoid races. + self.file.close() + + +def load_job(job_path): + # Windows PowerShell 5.1's UTF8 output includes a BOM. + with job_path.open(encoding="utf-8-sig") as source: + job = json.load(source) + if not isinstance(job, dict): + raise ValueError("Job must be a JSON object") + for key in ("input", "output", "preset"): + if not isinstance(job.get(key), str) or not job[key].strip(): + raise ValueError("Job requires a nonempty string: " + key) + if job["preset"] not in PRESETS: + raise ValueError("Unknown preset: " + job["preset"]) + quality = job.get("crf", 23) + if not isinstance(quality, int) or isinstance(quality, bool): + raise ValueError("crf must be an integer") + + def job_file(value): + path = Path(value).expanduser() + return (job_path.parent / path).resolve() + + input_path = job_file(job["input"]) + output_path = job_file(job["output"]) + if not input_path.is_file(): + raise ValueError("Input file does not exist: " + str(input_path)) + if input_path == output_path: + raise ValueError("Input and output must be different files") + if output_path.exists(): + raise ValueError("Output already exists: " + str(output_path)) + if not output_path.suffix: + raise ValueError("Output needs a container extension, such as .mp4") + return input_path, output_path, job["preset"], quality + + +def archive_job(job_path, directory, error=None): + target = directory / job_path.name + while target.exists() or target.with_suffix(target.suffix + ".err").exists(): + target = directory / (job_path.stem + "-" + uuid.uuid4().hex + ".json") + if error is not None: + target.with_suffix(target.suffix + ".err").write_text(error, encoding="utf-8") + job_path.rename(target) + return target + + +def process_job(job_path, done, failed, ffmpeg): + temporary_output = None + try: + input_path, output_path, preset, quality = load_job(job_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + # Keep the container suffix so ffmpeg can infer the output format. + temporary_output = output_path.with_name( + "." + output_path.stem + "-" + uuid.uuid4().hex + ".part" + output_path.suffix + ) + video_args = list(PRESETS[preset]) + if preset == "qsv_h264": + video_args += ["-global_quality", str(quality)] + else: + video_args += ["-cq", str(quality), "-b:v", "0"] + command = [ + ffmpeg, "-hide_banner", "-nostdin", "-n", "-i", str(input_path), + "-map", "0:v:0", "-map", "0:a:0?", + *video_args, "-pix_fmt", "nv12", "-c:a", "aac", "-b:a", "192k", + str(temporary_output), + ] + logging.info("Job %s: %s", job_path.name, subprocess.list2cmdline(command)) + with tempfile.TemporaryFile() as stderr: + result = subprocess.run(command, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=stderr, check=False) + if result.returncode != 0: + # Bound diagnostic size even for a long-running failed encode. + stderr.seek(0, os.SEEK_END) + stderr.seek(max(0, stderr.tell() - 65536)) + detail = stderr.read().decode("utf-8", errors="replace") + raise RuntimeError( + "ffmpeg exited with code {}\nCommand: {}\n\n{}".format( + result.returncode, subprocess.list2cmdline(command), detail + ) + ) + if not temporary_output.is_file() or temporary_output.stat().st_size == 0: + raise RuntimeError("ffmpeg returned success without a nonempty output") + # On Windows rename refuses to replace an existing destination. + temporary_output.rename(output_path) + archive_job(job_path, done) + logging.info("Completed %s -> %s", job_path.name, output_path) + return True + except Exception as error: + logging.error("Failed %s: %s", job_path.name, error) + archive_job(job_path, failed, "{}: {}\n".format(type(error).__name__, error)) + return False + finally: + if temporary_output is not None: + temporary_output.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inbox", type=Path, default=Path(r"C:\transcode-jobs\inbox"), + help="JSON inbox; done/failed are sibling directories") + parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable (default: PATH)") + parser.add_argument("--poll-interval", type=float, default=5, help="Poll interval in seconds") + parser.add_argument("--once", action="store_true", help="Process the current inbox and exit") + args = parser.parse_args() + if os.name != "nt": + parser.error("Run this worker with Windows Python directly, not inside WSL") + if not math.isfinite(args.poll_interval) or args.poll_interval <= 0: + parser.error("--poll-interval must be a finite positive number") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + inbox = args.inbox.resolve() + done, failed = inbox.parent / "done", inbox.parent / "failed" + for directory in (inbox, done, failed): + directory.mkdir(parents=True, exist_ok=True) + lock = WorkerLock(inbox) + try: + lock.__enter__() + except OSError as error: + logging.error("Cannot acquire worker lock (another worker may be running): %s", error) + return 1 + try: + logging.info("Polling %s", inbox) + had_failure = False + while True: + for job_path in sorted(inbox.glob("*.json")): + if job_path.is_file(): + had_failure = not process_job(job_path, done, failed, args.ffmpeg) or had_failure + if args.once: + return int(had_failure) + time.sleep(args.poll_interval) + except KeyboardInterrupt: + logging.info("Worker stopped") + return 0 + finally: + lock.__exit__() + + +if __name__ == "__main__": + raise SystemExit(main()) 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/(files-host)/files-app/page.tsx b/apps/web/src/app/(files-host)/files-app/page.tsx new file mode 100644 index 0000000..fcca6dc --- /dev/null +++ b/apps/web/src/app/(files-host)/files-app/page.tsx @@ -0,0 +1,9 @@ +import { FilesExplorer } from "../../../components/files/explorer/files-explorer"; + +/** + * Full-screen files experience. files./ is rewritten here by + * src/middleware.ts; the route also works directly on any host. + */ +export default function FilesAppPage(): React.ReactElement { + return ; +} diff --git a/apps/web/src/app/(files-host)/layout.tsx b/apps/web/src/app/(files-host)/layout.tsx new file mode 100644 index 0000000..6f0ef0f --- /dev/null +++ b/apps/web/src/app/(files-host)/layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import type { ReactNode } from "react"; +import { AuthProvider } from "../../lib/auth-store"; +import { AuthGate } from "../../components/auth/auth-gate"; +import { FilesLangProvider } from "../../components/files/i18n"; + +/** + * Shell for the dedicated files.* host. It sits OUTSIDE the (main) group on + * purpose: no hub sidebar, bottom nav or command palette — the explorer owns + * the whole viewport. Auth and the Files EN/ES preference are provided here + * the same way (main)/layout.tsx and (main)/files/layout.tsx do. + */ +export default function FilesHostLayout({ children }: { children: ReactNode }): React.ReactElement { + return ( + + + {children} + + + ); +} 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 a6491d5..234aa93 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 && ( +
+