From 8f01765df39bb89bcc0c36592cff307fb2443eb0 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20adapt=20T-HUB=20features=20=E2=80=94=20?= =?UTF-8?q?folders=20schema,=20Instagram=20connector,=20outreach=20contact?= =?UTF-8?q?s,=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 && ( +
+