diff --git a/README.md b/README.md index 650f286..3f5f716 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,259 @@ -# Tactical Media Broadcast Hub +# T-HUB + +

T-HUB

+ +Copia de trabajo del [Tactical Media Broadcast Hub](https://gitlab.moxexley.com/mediatools/tacticalmediahub) +para la red de acampadas: [acampada26s.net](https://acampada26s.net), +[tomalacalle.net](https://tomalacalle.net) y [takethestreet.net](https://takethestreet.net). +El despliegue en uso es [app.occupymedia.cc](https://app.occupymedia.cc). + +Seguimos la rama `main` de upstream y mandamos allí los cambios que les sirvan. +Lo propio de T-HUB va en este repo. Para revisar y validar los cambios, ver +[docs/T-HUB-VALIDACION.txt](docs/T-HUB-VALIDACION.txt). + +Cambios respecto a upstream: + +- Instagram registrado en conexión y publicación; claves de la app de Meta solo en el entorno del servidor. +- Panel **Outreach** para escribir por privado a mano, contacto a contacto, con seguimiento. +- RLS forzado en `media_assets` (migración 042). +- Estilo de acampada26s.net en la web: fondo blanco, tinta negra, neón `#00e5ff` en sombras y foco, títulos en Anton. Logo de tres tiendas cableadas a un switch con antena, dibujado como el de la acampada (`docs/brand/`). + +| Remoto | URL | +|---|---| +| `origin` | `https://gitea.laenre.net/hacklab/T-HUB.git` | +| `upstream` | `https://gitlab.moxexley.com/mediatools/tacticalmediahub.git` | + +```bash +git fetch upstream && git merge upstream/main +``` + +## Arquitectura + +### 1. Una cuenta + +Una persona conecta una cuenta del colectivo una sola vez. A partir de ahí +redacta, alguien aprueba y el Hub publica. La contraseña de la red social no +pasa nunca por el Hub: la red devuelve un token y el Hub lo guarda cifrado. + +```mermaid +flowchart LR + P([Persona]) -->|1. Conectar| H[T-HUB] + H -->|2. Pantalla de permiso| R[(Red social)] + R -->|3. Token| H + P -->|4. Redacta| H + H -->|5. Aprobación| A([Otra persona del colectivo]) + A -->|6. Visto bueno| H + H -->|7. Publica a su hora| R +``` + +### 2. Varias cuentas compartidas + +Cada colectivo tiene sus cuentas y sus reglas de aprobación. Un borrador puede +ir a varias cuentas a la vez: el Hub crea una copia por destino y cada una pasa +la regla de su cuenta (automática, una persona, mayoría o consenso). Lo que deba +salir en toda la red sube a la cola del colectivo padre. + +```mermaid +flowchart TB + subgraph TTS[TAKE THE STREET] + Q[Cola común] --> IG1[Instagram internacional] + Q --> M1[Mastodon @takethestreet] + end + subgraph S26[ACAMPADA 26S] + D[Borrador] --> IG2[Instagram @acampada26s] + D --> M2[Mastodon @26s] + D --> T2[Telegram acampada26s] + end + subgraph SUB[Acampadas locales] + B[Barcelona] --> IG3[Instagram propio] + S[Sevilla] --> IG4[Instagram propio] + end + S26 -->|Sube para toda la red| Q + SUB -->|Sube a 26S| D + IG2 -.->|Regla: 2 aprobaciones| G{{Aprobación}} + M2 -.->|Regla: automática| G +``` + +### 3. Parte técnica + +La web solo habla con la API. La API guarda el estado en Postgres con +aislamiento por colectivo (RLS), los tokens van en un vault cifrado y el +envío programado lo hace el scheduler con Temporal. Cada red es un conector; +fuera de los conectores no hay código que distinga redes. + +```mermaid +flowchart LR + subgraph Cliente + W[apps/web
Next.js] + end + subgraph Control + API[apps/api
NestJS] + SCH[apps/scheduler
Temporal worker] + REG[ConnectorRegistry
packages/connector-*] + end + subgraph Datos + PG[(PostgreSQL
RLS por colectivo)] + RD[(Redis
estado OAuth 10 min)] + V[Vault
AES-256-GCM + KEK] + S3[(S3 / R2
medios cifrados)] + end + subgraph Medios + MW[apps/media-worker
FFmpeg] + PT[PeerTube
tube.occupymedia.cc] + end + subgraph Redes + MA[Mastodon API] + IG[Graph API
Instagram] + TG[Telegram Bot API] + end + + W -->|HTTPS + sesión| API + API --> PG + API --> RD + API --> V + V --> PG + API -->|connect / identity| REG + SCH -->|publish| REG + SCH --> PG + REG -->|providerFetch
control de salida| MA + REG --> IG + REG --> TG + MW --> S3 + MW -->|RTMP| PT +``` + +Conectar una cuenta (OAuth con `state` de un solo uso y PKCE donde la red lo admite): + +1. `POST /connections/authorize`: la API guarda en Redis, cifrado, el estado de la petición y devuelve la URL de la red. +2. La persona acepta en la red y la red vuelve a `GET /connections/callback`. +3. La API comprueba `state`, cookie y permisos, canjea el código y guarda el token en el vault. + +## Estado por red + +| Red | Conectar | Publicar | Notas | +|---|---|---|---| +| Mastodon | Sí | Texto e imágenes (con texto alternativo) | Sin claves de servidor; cada cuenta registra la app en su instancia. | +| Telegram | Sí | Texto e imágenes | Token del bot de BotFather. | +| Instagram | Sí, con `INSTAGRAM_CLIENT_ID/SECRET` | No todavía | Meta descarga la imagen de una URL pública y los medios están cifrados: falta un endpoint temporal. Ver [docs/oauth-connect.md](docs/oauth-connect.md). | +| PeerTube | No | Directo sí | Falta el puente OAuth en tube.occupymedia.cc. | +| X | No | No | Sin conector. API de pago por uso. | +| TikTok | No | No | Fuera de alcance: la API exige auditoría y sin ella solo publica en privado. | + +## Panel Outreach (mensajes privados) + +Lista de cuentas públicas (artistas, colectivos, medios) a las que escribir por +privado para pedir difusión. Cada contacto tiene su mensaje, se puede rellenar +con una plantilla (`{name}`, `{handle}`) y se marca enviado, respondido o +rechazado. Se importa en CSV: + +``` +name,platform,handle,profileUrl,category,language +Climáximo,bluesky,climaximopt.bsky.social,https://bsky.app/profile/climaximopt.bsky.social,clima,pt +``` + +El Hub **no envía** los mensajes. "Copy & open profile" copia el texto y abre el +perfil; la persona lo pega y lo envía desde la propia red. Motivos: + +| Red | Abrir un privado por API | Problema | +|---|---|---| +| Instagram | No | La API solo deja responder a quien te escribió en las últimas 24 h. | +| X | Sí, de pago | Mensajes en masa a desconocidos van contra sus normas de automatización: suspensión. | +| Mastodon | Sí (mención privada) | Las instancias silencian o bloquean cuentas que lo hacen en masa; puede caer la instancia entera. | +| Bluesky | Sí | Mismo criterio de spam; los chats de desconocidos suelen estar cerrados. | + +El contador de la pantalla avisa a partir de 20 primeros mensajes al día por +colectivo. Repartidlos entre varias personas y días, y personalizad cada uno. + +## Requisitos + +Medido en el despliegue actual (ver +[docs/ops/2026-10-12-cloud-hosting-requirements.md](docs/ops/2026-10-12-cloud-hosting-requirements.md)). +Los medios van a R2, así que el disco no crece con los vídeos. + +| Nivel | CPU | RAM | Disco | Para qué | +|---|---|---|---|---| +| Mínimo | 4 vCPU | 8 GB | 160 GB SSD | Todo el stack, transcodificación fuera de la máquina | +| Recomendado | 8 vCPU | 16 GB | 240 GB SSD | Directos, transcodificación en la máquina, Collabora | +| Nodo de render (opcional) | 4+ núcleos o GPU con NVENC | 8 GB | 50 GB | Se une por Tailscale; nunca guarda claves | + +Uso real hoy: unos 2,6 GB de RAM entre todos los servicios. + +Ancho de banda: + +| Uso | Necesario | +|---|---| +| Panel, publicación, APIs de redes | < 1 Mbps sostenido | +| Un directo saliente (1080p30 hacia PeerTube) | 6 Mbps de subida por directo; 3 Mbps a 720p | +| Espectadores | Los sirve Cloudflare desde la caché de HLS; el origen no escala con la audiencia | +| Subidas de vídeo | Picos de lo que dé la línea; límite de 2 GiB por archivo | + +Coste orientativo: VPS de 30 €/mes + R2 de 1 a 5 $/mes. Cloudflare en plan gratuito. + +## Red y puertos + +Entrada desde internet: **ninguna** directa. Todo entra por el túnel de +Cloudflare; el nginx de borde solo acepta rangos de Cloudflare en 80/443. + +| Puerto | Servicio | Exponer a | +|---|---|---| +| 443/tcp | nginx de borde | Solo rangos de Cloudflare | +| 80/tcp | nginx de borde (redirección) | Solo rangos de Cloudflare | +| 3000/tcp | API | Solo 127.0.0.1 | +| 3001/tcp | Web | Solo 127.0.0.1 | +| 9000/tcp | PeerTube | Solo 127.0.0.1 o Tailscale | +| 1935/tcp | RTMP de PeerTube | Solo Tailscale (worker de medios) | +| 6167/tcp | Matrix (conduwuit) | Solo 127.0.0.1 | +| 9200, 9980/tcp | oCIS, Collabora | Solo 127.0.0.1 | +| 5432, 6379, 7233 | Postgres, Redis, Temporal | Nunca fuera de la red de Docker | + +Salida que hace falta: 443/tcp a las APIs de las redes y a R2, 7844/tcp+udp +para `cloudflared`, 41641/udp para Tailscale. + +Docker publica puertos saltándose ufw. Para cerrarlos: publicar en `127.0.0.1:PUERTO` +o en la IP de Tailscale, o añadir reglas en la cadena `DOCKER-USER`. + +## Seguridad y monitorización + +Prácticas para cualquier despliegue. Los fallos de seguridad se comunican en +privado a quien mantiene el código, no en issues públicas. + +- **Registro solo por invitación.** Un registro abierto da a cualquiera un colectivo propio y permisos de Owner sobre él. +- **Un secreto por servicio.** La web y los workers no necesitan `SUPERPOSTER_KEK`, `S3_KEK` ni `SESSION_SECRET`. Claves distintas para el vault y para los medios. +- **FFmpeg siempre en sandbox** (`NODE_ENV=production`, `MEDIA_SANDBOX=on`), sin red y sin variables de entorno heredadas. +- **Cabeceras**: CSP estricta, HSTS, `X-Content-Type-Options`, `Referrer-Policy` en web y API. +- **Límites de peticiones** en `/auth/*`, invitaciones, OIDC y subidas públicas, por `CF-Connecting-IP`. +- **Metadatos fuera**: quitar EXIF/GPS de fotos y vídeos antes de guardarlos o publicarlos. +- **Copias cifradas** (`age` o GPG) y fuera de la máquina; probar la restauración. +- **Auditoría inalterable**: quitar `UPDATE`/`DELETE` sobre `audit_log` al rol `sp_app` y anclar fuera la cabeza de la cadena. + +Qué vigilar (Prometheus + Grafana ya están en `infra/`; Grafana nunca con acceso anónimo): + +| Señal | Alerta si | +|---|---| +| `401`/`403` en `/auth/login` por IP | > 20 en 5 min | +| Registros nuevos / invitaciones canjeadas | Cualquiera fuera de lo previsto | +| Eventos `audit_log`: `member.*`, `connection.removed`, `group.deleted`, `outreach.removed` | Picos o actores raros | +| `connections` en estado `needs_credentials` | Cualquiera: token revocado o caducado | +| Errores 5xx de la API | > 1 % durante 5 min | +| Memoria de API y workers | > 80 % | +| Cola de publicación `failed`/`unknown` | Cualquiera | +| Logins al panel de Cloudflare y cambios de DNS | Siempre | + +## Desarrollo + +```bash +corepack enable +pnpm install +pnpm exec turbo run build typecheck --filter=@tactical-media-broadcast-hub/api... +pnpm --filter @tactical-media-broadcast-hub/connector-instagram test +``` + +Los tests de integración de `apps/api` que usan Postgres se saltan si no hay +base de datos configurada. + +--- + +## Upstream: Tactical Media Broadcast Hub Self-hosted multi-tenant social / video / livestream management platform with a generic connector system and a Virtual Channel / Auto Director subsystem. @@ -8,7 +263,7 @@ concentrating access in one person's hands. Every credential lives in an encrypted vault. Every editorial decision lands in a tamper-evident audit log. Every feature is multi-tenant with row-level security. -## Features +### Features - **Publishing pipeline** — drafts → group approval → schedule → fenced dispatch → receipts. Per-connection publish governance: `auto`, one-admin, majority or consensus sign-off per account — boosts included. Multi-platform fan-out (Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack). DST-proof scheduling. - **Files SSO** — the Hub is the OIDC issuer for oCIS Drive: one sign-in, auto-provisioned personal drives, rotating refresh tokens that die with your Hub session (logout propagates). @@ -20,7 +275,7 @@ Every feature is multi-tenant with row-level security. - **Archive** — content-addressed encrypted S3, dedup, full-text search, CSV/JSON export. Deleted platform content stays citable. - **Analytics** — month-partitioned snapshots, current + previous month with deltas, per-provider breakdown. -## Quickstart +### Quickstart ```bash pnpm install @@ -38,7 +293,7 @@ curl localhost:3000/healthz curl localhost:3000/readyz ``` -## Documentation +### Documentation | Section | Path | |---|---| @@ -49,13 +304,13 @@ curl localhost:3000/readyz | Streaming architecture | [`docs/design/streaming-architecture.md`](docs/design/streaming-architecture.md) | | Deployment (occupymedia.cc) | [`infra/occupymedia/DEPLOY.md`](infra/occupymedia/DEPLOY.md) | -## Deterministic live-source testing +### Deterministic live-source testing ```bash pnpm --filter @tactical-media-broadcast-hub/media-worker soak -- --hours 8 --out ``` -## Security & trust model +### Security & trust model Self-hosted, end to end — no corporate cloud, no accounts needed to run the system itself. Multi-tenant with row-level security; platform credentials in an diff --git a/apps/api/migrations/041_outreach.sql b/apps/api/migrations/041_outreach.sql new file mode 100644 index 0000000..b454a62 --- /dev/null +++ b/apps/api/migrations/041_outreach.sql @@ -0,0 +1,51 @@ +-- 041: outreach — per-workspace list of public accounts to contact by hand. +-- +-- The Hub never sends these messages. A person opens the contact's profile, +-- pastes the drafted text into the platform's own DM box and marks the row +-- sent; replies are marked the same way. Only public accounts belong here: +-- names and contact details of private persons stay out of the Hub. + +CREATE TABLE outreach_contacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + platform text NOT NULL CHECK (platform ~ '^[a-z0-9][a-z0-9-]{1,31}$'), + handle text NOT NULL CHECK (char_length(handle) BETWEEN 1 AND 200), + profile_url text NOT NULL CHECK (profile_url ~ '^https://[^\s]+$' AND char_length(profile_url) <= 500), + category text CHECK (category IS NULL OR char_length(category) <= 100), + language text CHECK (language IS NULL OR language ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$'), + message text NOT NULL DEFAULT '' CHECK (char_length(message) <= 4000), + notes text NOT NULL DEFAULT '' CHECK (char_length(notes) <= 2000), + status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'sent', 'replied', 'declined')), + sent_at timestamptz, + sent_by uuid REFERENCES users(id) ON DELETE SET NULL, + replied_at timestamptz, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, workspace_id) +); + +CREATE UNIQUE INDEX outreach_contacts_account ON outreach_contacts (workspace_id, platform, lower(handle)); +CREATE INDEX outreach_contacts_status ON outreach_contacts (workspace_id, status); + +CREATE TRIGGER trg_outreach_contacts_ws_immutable BEFORE UPDATE ON outreach_contacts + FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); + +ALTER TABLE outreach_contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE outreach_contacts FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON outreach_contacts + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) + WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); +GRANT SELECT, INSERT, UPDATE, DELETE ON outreach_contacts TO sp_app; + +-- Reads for anyone who already sees the inbox; edits for the roles that write drafts. +INSERT INTO role_permissions (role, permission) +SELECT 'Owner', 'outreach.view' +UNION ALL SELECT 'Admin', 'outreach.view' +UNION ALL SELECT 'Editor', 'outreach.view' +UNION ALL SELECT 'Moderator', 'outreach.view' +UNION ALL SELECT 'Owner', 'outreach.manage' +UNION ALL SELECT 'Admin', 'outreach.manage' +UNION ALL SELECT 'Editor', 'outreach.manage' +ON CONFLICT DO NOTHING; diff --git a/apps/api/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/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/app.module.ts b/apps/api/src/app.module.ts index ee1dd29..1e0e693 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -43,9 +43,10 @@ import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js"; import { OidcModule } from "./oidc/oidc.module.js"; import { SubmissionsModule } from "./submissions/submissions.module.js"; import { MediaModule } from "./media/media.module.js"; +import { OutreachModule } from "./outreach/outreach.module.js"; @Module({ - imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule], + imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule, OutreachModule], controllers: [ DemoController, PublishingController, diff --git a/apps/api/src/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/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/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/api/src/publishing/publishing.service.ts b/apps/api/src/publishing/publishing.service.ts index f32d8c1..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); @@ -654,7 +657,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) { diff --git a/apps/api/src/video/test-fixtures.ts b/apps/api/src/video/test-fixtures.ts index 8e61d08..45640cb 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> { @@ -88,6 +90,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 e08c310..a40e5b9 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 72cfd68..ebec38b 100644 --- a/apps/api/src/video/video.service.ts +++ b/apps/api/src/video/video.service.ts @@ -381,6 +381,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 *`, @@ -595,3 +596,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/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/public/brand/empty-state.svg b/apps/web/public/brand/empty-state.svg index 6dc6b6f..fa0020e 100644 --- a/apps/web/public/brand/empty-state.svg +++ b/apps/web/public/brand/empty-state.svg @@ -1,11 +1,11 @@ - + + fill="none" stroke="#000000" stroke-width="75" stroke-linecap="round" stroke-linejoin="round"/> - - + + \ No newline at end of file diff --git a/apps/web/public/brand/favicon.svg b/apps/web/public/brand/favicon.svg index cef6a31..57cf12e 100644 --- a/apps/web/public/brand/favicon.svg +++ b/apps/web/public/brand/favicon.svg @@ -1,7 +1,44 @@ - - - - + + T-HUB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + + + + diff --git a/apps/web/public/brand/icon-bluesky.svg b/apps/web/public/brand/icon-bluesky.svg index 7295511..8ecff66 100644 --- a/apps/web/public/brand/icon-bluesky.svg +++ b/apps/web/public/brand/icon-bluesky.svg @@ -1,6 +1,6 @@ Bluesky - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-discord.svg b/apps/web/public/brand/icon-discord.svg index cf2d999..37f9d2a 100644 --- a/apps/web/public/brand/icon-discord.svg +++ b/apps/web/public/brand/icon-discord.svg @@ -1,6 +1,6 @@ Discord - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-facebook.svg b/apps/web/public/brand/icon-facebook.svg index 2359872..6bcc22d 100644 --- a/apps/web/public/brand/icon-facebook.svg +++ b/apps/web/public/brand/icon-facebook.svg @@ -1,6 +1,6 @@ Facebook - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-instagram.svg b/apps/web/public/brand/icon-instagram.svg index edfe6bd..486e018 100644 --- a/apps/web/public/brand/icon-instagram.svg +++ b/apps/web/public/brand/icon-instagram.svg @@ -1,6 +1,6 @@ Instagram - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-linkedin.svg b/apps/web/public/brand/icon-linkedin.svg index 7797174..7e693d3 100644 --- a/apps/web/public/brand/icon-linkedin.svg +++ b/apps/web/public/brand/icon-linkedin.svg @@ -1,6 +1,6 @@ Linkedin - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-mastodon.svg b/apps/web/public/brand/icon-mastodon.svg index 2d92c11..39e42f4 100644 --- a/apps/web/public/brand/icon-mastodon.svg +++ b/apps/web/public/brand/icon-mastodon.svg @@ -1,6 +1,6 @@ Mastodon - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-peertube.svg b/apps/web/public/brand/icon-peertube.svg index 787cdb0..a22704a 100644 --- a/apps/web/public/brand/icon-peertube.svg +++ b/apps/web/public/brand/icon-peertube.svg @@ -1,6 +1,6 @@ Peertube - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-reddit.svg b/apps/web/public/brand/icon-reddit.svg index 497ad38..8d135d5 100644 --- a/apps/web/public/brand/icon-reddit.svg +++ b/apps/web/public/brand/icon-reddit.svg @@ -1,6 +1,6 @@ Reddit - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-slack.svg b/apps/web/public/brand/icon-slack.svg index 21dbdbc..3c39cd5 100644 --- a/apps/web/public/brand/icon-slack.svg +++ b/apps/web/public/brand/icon-slack.svg @@ -1,6 +1,6 @@ Slack - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-telegram.svg b/apps/web/public/brand/icon-telegram.svg index 2c04c3b..0738b13 100644 --- a/apps/web/public/brand/icon-telegram.svg +++ b/apps/web/public/brand/icon-telegram.svg @@ -1,6 +1,6 @@ Telegram - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-threads.svg b/apps/web/public/brand/icon-threads.svg index 87aa902..fd6e59e 100644 --- a/apps/web/public/brand/icon-threads.svg +++ b/apps/web/public/brand/icon-threads.svg @@ -1,6 +1,6 @@ Threads - - + \ No newline at end of file diff --git a/apps/web/public/brand/icon-twitch.svg b/apps/web/public/brand/icon-twitch.svg index dd99360..7cb2747 100644 --- a/apps/web/public/brand/icon-twitch.svg +++ b/apps/web/public/brand/icon-twitch.svg @@ -1,6 +1,6 @@ Twitch - - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-analytics.svg b/apps/web/public/brand/nav-analytics.svg index 96c623d..d86f160 100644 --- a/apps/web/public/brand/nav-analytics.svg +++ b/apps/web/public/brand/nav-analytics.svg @@ -1,4 +1,4 @@ Nav Analytics - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-approvals.svg b/apps/web/public/brand/nav-approvals.svg index dcb2371..d90b1ce 100644 --- a/apps/web/public/brand/nav-approvals.svg +++ b/apps/web/public/brand/nav-approvals.svg @@ -1,4 +1,4 @@ Nav Approvals - + diff --git a/apps/web/public/brand/nav-audit.svg b/apps/web/public/brand/nav-audit.svg index 82dd8ef..2ae85f9 100644 --- a/apps/web/public/brand/nav-audit.svg +++ b/apps/web/public/brand/nav-audit.svg @@ -1,4 +1,4 @@ Nav Audit - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-calendar.svg b/apps/web/public/brand/nav-calendar.svg index 1398cca..b77a972 100644 --- a/apps/web/public/brand/nav-calendar.svg +++ b/apps/web/public/brand/nav-calendar.svg @@ -1,4 +1,4 @@ Nav Calendar - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-chat.svg b/apps/web/public/brand/nav-chat.svg index 61a9adb..6016011 100644 --- a/apps/web/public/brand/nav-chat.svg +++ b/apps/web/public/brand/nav-chat.svg @@ -1,7 +1,7 @@ Nav Chat - - - - + + + + diff --git a/apps/web/public/brand/nav-compose.svg b/apps/web/public/brand/nav-compose.svg index eb9228c..e9b41fa 100644 --- a/apps/web/public/brand/nav-compose.svg +++ b/apps/web/public/brand/nav-compose.svg @@ -1,4 +1,4 @@ Nav Compose - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-connections.svg b/apps/web/public/brand/nav-connections.svg index a6f759c..f5cd986 100644 --- a/apps/web/public/brand/nav-connections.svg +++ b/apps/web/public/brand/nav-connections.svg @@ -1,4 +1,4 @@ Nav Connections - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-files.svg b/apps/web/public/brand/nav-files.svg index 7bdd812..7d00638 100644 --- a/apps/web/public/brand/nav-files.svg +++ b/apps/web/public/brand/nav-files.svg @@ -1,4 +1,4 @@ Nav Files - + diff --git a/apps/web/public/brand/nav-home.svg b/apps/web/public/brand/nav-home.svg index c324549..9d9fc9e 100644 --- a/apps/web/public/brand/nav-home.svg +++ b/apps/web/public/brand/nav-home.svg @@ -1,4 +1,4 @@ Nav Home - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-inbox.svg b/apps/web/public/brand/nav-inbox.svg index fb4dde6..260580a 100644 --- a/apps/web/public/brand/nav-inbox.svg +++ b/apps/web/public/brand/nav-inbox.svg @@ -1,4 +1,4 @@ Nav Inbox - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-more.svg b/apps/web/public/brand/nav-more.svg index 86d7e8e..6adaa7f 100644 --- a/apps/web/public/brand/nav-more.svg +++ b/apps/web/public/brand/nav-more.svg @@ -1,4 +1,4 @@ Nav More - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-search.svg b/apps/web/public/brand/nav-search.svg index 424d7fe..8135375 100644 --- a/apps/web/public/brand/nav-search.svg +++ b/apps/web/public/brand/nav-search.svg @@ -1,4 +1,4 @@ Nav Search - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-settings.svg b/apps/web/public/brand/nav-settings.svg index 6ab306a..1eb2864 100644 --- a/apps/web/public/brand/nav-settings.svg +++ b/apps/web/public/brand/nav-settings.svg @@ -1,4 +1,4 @@ Nav Settings - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-team.svg b/apps/web/public/brand/nav-team.svg index 0a5fe68..b9ebcbe 100644 --- a/apps/web/public/brand/nav-team.svg +++ b/apps/web/public/brand/nav-team.svg @@ -1,4 +1,4 @@ Nav Team - + \ No newline at end of file diff --git a/apps/web/public/brand/nav-video.svg b/apps/web/public/brand/nav-video.svg index 74e982a..8fe1c5e 100644 --- a/apps/web/public/brand/nav-video.svg +++ b/apps/web/public/brand/nav-video.svg @@ -1,4 +1,4 @@ Nav Video - + diff --git a/apps/web/public/brand/pattern.svg b/apps/web/public/brand/pattern.svg index 8a38621..0645eea 100644 --- a/apps/web/public/brand/pattern.svg +++ b/apps/web/public/brand/pattern.svg @@ -3,8 +3,8 @@ - - + + diff --git a/apps/web/public/brand/spinner.svg b/apps/web/public/brand/spinner.svg index ca6de79..d49dd42 100644 --- a/apps/web/public/brand/spinner.svg +++ b/apps/web/public/brand/spinner.svg @@ -1,8 +1,8 @@ - - - - + + + + \ No newline at end of file diff --git a/apps/web/public/brand/t-hub.svg b/apps/web/public/brand/t-hub.svg new file mode 100644 index 0000000..57cf12e --- /dev/null +++ b/apps/web/public/brand/t-hub.svg @@ -0,0 +1,44 @@ + + T-HUB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/fonts/Anton-OFL.txt b/apps/web/public/fonts/Anton-OFL.txt new file mode 100644 index 0000000..93fedde --- /dev/null +++ b/apps/web/public/fonts/Anton-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/public/fonts/anton.woff2 b/apps/web/public/fonts/anton.woff2 new file mode 100644 index 0000000..46fd713 Binary files /dev/null and b/apps/web/public/fonts/anton.woff2 differ diff --git a/apps/web/src/app/(main)/control-room/control-room.css b/apps/web/src/app/(main)/control-room/control-room.css index 265c367..9ea7701 100644 --- a/apps/web/src/app/(main)/control-room/control-room.css +++ b/apps/web/src/app/(main)/control-room/control-room.css @@ -13,4 +13,4 @@ } .control-room label { max-width: 100%; } .control-room button { min-width: 44px; } -.control-room :focus-visible { outline: 2px solid #ff1610; outline-offset: 3px; } +.control-room :focus-visible { outline: 2px solid #00e5ff; outline-offset: 3px; } diff --git a/apps/web/src/app/(main)/layout.tsx b/apps/web/src/app/(main)/layout.tsx index 2ab0dfb..2d2ff28 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(); @@ -49,7 +49,7 @@ function Shell({ children }: { children: ReactNode }): React.ReactElement { scrollPositions.set(pathname, { main: main.scrollTop, nested: scrollableChildren(main).map((element) => element.scrollTop) }); }; }, [pathname, mobile]); - const title = titles[pathname.split("/")[1] ?? ""] ?? "Tactical Media Broadcast Hub"; + const title = titles[pathname.split("/")[1] ?? ""] ?? "T-HUB"; return (
diff --git a/apps/web/src/app/(main)/more/page.tsx b/apps/web/src/app/(main)/more/page.tsx index 3f40f0e..14e6f18 100644 --- a/apps/web/src/app/(main)/more/page.tsx +++ b/apps/web/src/app/(main)/more/page.tsx @@ -48,7 +48,7 @@ export default function MorePage(): React.ReactElement {
-

{session?.user.displayName || session?.user.username || "Tactical Media Broadcast Hub"}

+

{session?.user.displayName || session?.user.username || "T-HUB"}

@{session?.user.username || "offline"}{session?.user.email ? ` · ${session.user.email}` : ""}

{session?.role || "Preview"}{session?.workspace.name || "Studio"}
@@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {

Settings

+ diff --git a/apps/web/src/app/(main)/onboarding/page.tsx b/apps/web/src/app/(main)/onboarding/page.tsx index d1287a1..597dea8 100644 --- a/apps/web/src/app/(main)/onboarding/page.tsx +++ b/apps/web/src/app/(main)/onboarding/page.tsx @@ -221,15 +221,15 @@ export default function OnboardingPage(): React.ReactElement {
- +
Horizontal Collaborative Broadcast System
-
Tactical Media Broadcast Hub
+
T-HUB
-

Tactical Media Broadcast Hub

+

T-HUB

Your platform. Their feeds. No algorithm in between.

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 && ( +
+