merge: adapt/T-HUB features — Instagram connector, outreach contacts, folders schema, media RLS force

This commit is contained in:
glass beadian 2026-10-18 10:41:13 +00:00
commit 434e490f28
40 changed files with 3236 additions and 86 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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",

View file

@ -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,

View file

@ -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 ?? "";

View file

@ -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;
}

View file

@ -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<MediaAssetRecord & { bytes: Buffer }> {
const asset = await withTenant(workspaceId, async (client) => {
const result = await client.query<MediaRow>("SELECT * FROM media_assets WHERE id = $1", [id]);
const result = await client.query<MediaRow>("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<void> {
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);

View file

@ -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<OutreachContact[]> {
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<OutreachContact> {
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<void> {
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;
}

View file

@ -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 {}

View file

@ -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<OutreachContact[]> {
return withTenant(workspaceId, async (client) => {
const result = await client.query<OutreachRow>(
`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<OutreachContact> {
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<OutreachRow>(
`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<void> {
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<string, string | null> {
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,
};
}

View file

@ -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<string, unknown> = {}) => ({
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<unknown>) =>
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();
});
});

View file

@ -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<void> {
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) {

View file

@ -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<Record<string, unknown>>): QueryResult<Record<string, unknown>> {
@ -89,6 +91,9 @@ export function serviceWithDatabase(
if (sql === "SELECT * FROM video_assets WHERE id = $1") {
return result(database.asset === undefined ? [] : [database.asset]);
}
if (sql.includes("branding->>'logoAssetKey'")) {
return result(database.ownedImageKeys?.includes(String(values[0])) === true ? [{}] : []);
}
if (sql.includes("SET state = 'probing'")) {
if (database.job?.state !== "queued") return result([]);
database.job.state = "probing";

View file

@ -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 });

View file

@ -544,6 +544,7 @@ export class VideoService {
const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
await assertLogoKeysOwned(client, workspaceId, request);
const jobResult = await client.query(
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)
VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`,
@ -774,3 +775,25 @@ function decodeCursor(cursor: string): VideoCursor {
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
/**
* Storage keys are content-addressed in one shared bucket, so a well-formed key
* alone proves nothing about who owns the object. A logo overlay may only use
* an image this workspace holds: an attachment, a group asset or its branding.
*/
async function assertLogoKeysOwned(
client: { query: (sql: string, values: unknown[]) => Promise<{ rowCount: number | null }> },
workspaceId: string,
request: ProduceRequest,
): Promise<void> {
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");
}
}

View file

@ -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 <job>.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')"

View file

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

View file

@ -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())

View file

@ -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:^",

View file

@ -0,0 +1,9 @@
import { FilesExplorer } from "../../../components/files/explorer/files-explorer";
/**
* Full-screen files experience. files.<domain>/ is rewritten here by
* src/middleware.ts; the route also works directly on any host.
*/
export default function FilesAppPage(): React.ReactElement {
return <FilesExplorer />;
}

View file

@ -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 (
<AuthProvider>
<FilesLangProvider>
<AuthGate>{children}</AuthGate>
</FilesLangProvider>
</AuthProvider>
);
}

View file

@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
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<string, { main: number; nested: number[] }>();

View file

@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
<div className="border-y border-border-subtle bg-surface">
<Row href="/connections" title="Connections" detail="Accounts and health" />
<Row href="/outreach" title="Outreach" detail="Contacts to write to by hand" />
<Row href="/settings" title="Settings" />
<Row href="/groups" title="Groups" detail="Collective moderation — submissions, approvals" />
<Row href="/calendar" title="Calendar" />

View file

@ -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<OutreachStatus, "neutral" | "accent" | "live" | "danger"> = {
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<OutreachContact[], OutreachContact[]>("/outreach/contacts", identity, SAMPLE);
const live = contacts.source === "live";
const canManage = live && can("outreach.manage");
const [filter, setFilter] = useState<OutreachStatus | "all">("todo");
const [category, setCategory] = useState<string>("all");
const [openId, setOpenId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(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<OutreachStatus, number>;
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<Pick<OutreachContact, "message" | "notes" | "status">>): Promise<void> {
try {
replace(await api.patch<OutreachContact>(`/outreach/contacts/${id}`, body));
} catch (err) {
setNotice(errorMessage(err));
}
}
async function copyAndOpen(contact: OutreachContact): Promise<void> {
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<void> {
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<void> {
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 (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">Outreach</h1>
<OfflineBadge source={contacts.source} />
{!live && <Badge tone="warn">read-only sample</Badge>}
</div>
<p className="mb-4 max-w-2xl text-xs leading-relaxed text-text-faint">
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.
</p>
<div className="mb-4 flex flex-wrap items-center gap-2">
{(["all", ...OUTREACH_STATUSES] as const).map((status) => (
<Button key={status} size="sm" variant={filter === status ? "outline" : "ghost"} onClick={() => setFilter(status)}>
{status} {status === "all" ? contacts.data.length : counts[status]}
</Button>
))}
{categories.length > 0 && (
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-2xs"
aria-label="Category"
>
<option value="all">all categories</option>
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
)}
<span className="ml-auto text-2xs text-text-faint">
Sent today: <span className={today >= OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today}</span>
</span>
</div>
{today >= OUTREACH_DAILY_SOFT_CAP && (
<Card className="mb-4 p-3">
<p className="text-xs text-warn">
{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.
</p>
</Card>
)}
{(notice !== null || contacts.error !== null) && (
<Card className="mb-4 flex items-start justify-between gap-3 p-3">
<p className={`text-xs ${contacts.error !== null ? "text-danger" : "text-text-dim"}`}>{contacts.error ?? notice}</p>
{notice !== null && <Button size="sm" onClick={() => setNotice(null)}>Dismiss</Button>}
</Card>
)}
{canManage && <Tools busy={busy} onImported={contacts.reload} onTemplate={applyTemplate} onNotice={setNotice} />}
{contacts.source === "loading" ? (
<div className="flex justify-center py-10"><Loading size={28} label="Loading contacts" /></div>
) : visible.length === 0 ? (
<EmptyState title="No contacts here" hint={canManage ? "Import a list above or change the filter." : "Change the filter."} />
) : (
<div className="flex flex-col gap-2">
{visible.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
open={openId === contact.id}
canManage={canManage}
onToggle={() => setOpenId(openId === contact.id ? null : contact.id)}
onPatch={(body) => patch(contact.id, body)}
onCopyOpen={() => copyAndOpen(contact)}
onRemove={() => remove(contact)}
/>
))}
</div>
)}
</div>
);
}
function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: {
contact: OutreachContact;
open: boolean;
canManage: boolean;
onToggle: () => void;
onPatch: (body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>) => Promise<void>;
onCopyOpen: () => Promise<void>;
onRemove: () => Promise<void>;
}): React.ReactElement {
const [message, setMessage] = useState(contact.message);
const [notes, setNotes] = useState(contact.notes);
return (
<Card className="p-3">
<button type="button" onClick={onToggle} className="flex w-full flex-wrap items-center gap-2 text-left">
<span className="text-sm font-medium">{contact.name}</span>
<span className="text-2xs text-text-faint">{contact.platform} · {contact.handle}</span>
{contact.category !== null && <Badge>{contact.category}</Badge>}
{contact.language !== null && <Badge>{contact.language}</Badge>}
<span className="ml-auto"><Badge tone={STATUS_TONE[contact.status]}>{contact.status}</Badge></span>
</button>
{open && (
<div className="mt-3 flex flex-col gap-2">
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onBlur={() => { if (canManage && message !== contact.message) void onPatch({ message }); }}
rows={5}
maxLength={4000}
readOnly={!canManage}
placeholder="Message for this contact"
className={TEXTAREA}
/>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
onBlur={() => { if (canManage && notes !== contact.notes) void onPatch({ notes }); }}
rows={2}
maxLength={2000}
readOnly={!canManage}
placeholder="Notes (who wrote, what they said)"
className={TEXTAREA}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void onCopyOpen()}>Copy &amp; open profile</Button>
{canManage && contact.status !== "sent" && <Button variant="outline" size="sm" onClick={() => void onPatch({ status: "sent" })}>Mark sent</Button>}
{canManage && contact.status !== "replied" && <Button size="sm" onClick={() => void onPatch({ status: "replied" })}>Replied</Button>}
{canManage && contact.status !== "declined" && <Button size="sm" onClick={() => void onPatch({ status: "declined" })}>Declined</Button>}
{canManage && contact.status !== "todo" && <Button size="sm" onClick={() => void onPatch({ status: "todo" })}>Back to pending</Button>}
{canManage && <Button variant="danger" size="sm" className="ml-auto" onClick={() => void onRemove()}>Remove</Button>}
</div>
{contact.sentAt !== null && (
<p className="text-2xs text-text-faint">
Sent {new Date(contact.sentAt).toLocaleString()}
{contact.repliedAt !== null && ` · replied ${new Date(contact.repliedAt).toLocaleString()}`}
</p>
)}
</div>
)}
</Card>
);
}
function Tools({ busy, onImported, onTemplate, onNotice }: {
busy: boolean;
onImported: () => void;
onTemplate: (template: string) => Promise<void>;
onNotice: (message: string) => void;
}): React.ReactElement {
const [csv, setCsv] = useState("");
const [template, setTemplate] = useState("");
const [importing, setImporting] = useState(false);
async function submit(): Promise<void> {
const { rows, errors } = parseImport(csv);
if (errors.length > 0) {
onNotice(errors.slice(0, 3).join("; "));
return;
}
if (rows.length === 0) return;
setImporting(true);
try {
let added = 0;
let skipped = 0;
for (let start = 0; start < rows.length; start += 500) {
const result = await api.post<{ added: number; skipped: number }>("/outreach/contacts", { contacts: rows.slice(start, start + 500) });
added += result.added;
skipped += result.skipped;
}
onNotice(`${added} contacts added, ${skipped} already on the list.`);
setCsv("");
onImported();
} catch (err) {
onNotice(errorMessage(err));
} finally {
setImporting(false);
}
}
return (
<div className="mb-4 grid gap-3 lg:grid-cols-2">
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Import</h2>
<p className="mb-2 text-2xs text-text-faint">One per line: name, platform, handle, profileUrl, category, language</p>
<textarea
value={csv}
onChange={(event) => setCsv(event.target.value)}
rows={4}
placeholder="Name,platform,handle,https://profile-url,category,es"
className={`${TEXTAREA} font-mono text-2xs`}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={importing || csv.trim() === ""} onClick={() => void submit()}>
{importing ? "Importing…" : "Import"}
</Button>
</Card>
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Template</h2>
<p className="mb-2 text-2xs text-text-faint">
Fills pending contacts in the current view that have no message yet. Use {"{name}"} and {"{handle}"}.
</p>
<textarea
value={template}
onChange={(event) => setTemplate(event.target.value)}
rows={4}
placeholder="Hola {name}, os escribimos desde la Acampada 26S…"
className={TEXTAREA}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={busy || template.trim() === ""} onClick={() => void onTemplate(template)}>
{busy ? "Applying…" : "Apply to pending"}
</Button>
</Card>
</div>
);
}

View file

@ -104,9 +104,15 @@ function ManualLanding(): React.ReactElement {
}
}
// files.* host: middleware normally rewrites "/" to the explorer; this is the
// fallback when a proxy hid the Host header from it.
useEffect(() => {
if (window.location.hostname.startsWith("files.")) router.replace("/files-app");
}, [router]);
// Signed-in visitors go straight to the app; the manual stays reachable at /wiki.
useEffect(() => {
if (status === "authenticated") router.replace("/streams");
if (status === "authenticated" && !window.location.hostname.startsWith("files.")) router.replace("/streams");
}, [status, router]);
const t = <T extends { es: string; en: string }>(text: T): string => text[lang];

View file

@ -0,0 +1,155 @@
"use client";
import { Badge, Button } from "../../ui/primitives";
import { formatBytes } from "../../video/format";
import { MimeIcon } from "../mime-icon";
import { LEGAL_HOLD_LABEL, mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "../labels";
import { useFilesLang } from "../i18n";
import type { ExplorerFile } from "./explorer-api";
/**
* Details drawer for the selected file: right rail on desktop, bottom sheet on
* mobile. Metadata on top, then every action the caller may take. Actions whose
* endpoint is not live yet render disabled with a "coming soon" note; Delete is
* not rendered at all unless the caller is permitted.
*/
export type DrawerFeature = "move" | "copy" | "shareToGroup" | "delete";
export function DetailsDrawer({
item,
live,
location,
personal,
viewable,
canRetention,
canDelete,
hasActiveShare,
busy,
unavailable,
onClose,
onView,
onDownload,
onShare,
onRevoke,
onRetention,
onMove,
onCopy,
onShareToGroup,
onDelete,
}: {
item: ExplorerFile;
live: boolean;
/** Human path of the folder the item sits in. */
location: string;
personal: boolean;
viewable: boolean;
canRetention: boolean;
canDelete: boolean;
hasActiveShare: boolean;
busy: boolean;
unavailable: ReadonlySet<DrawerFeature>;
onClose: () => void;
onView: () => void;
onDownload: () => void;
onShare: () => void;
onRevoke: () => void;
onRetention: () => void;
onMove: () => void;
onCopy: () => void;
onShareToGroup: () => void;
onDelete: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const disabled = busy || !live || item.status !== "available";
const soon = t({ en: "coming soon", es: "próximamente" });
const retentionLabel =
item.retention.mode === "until" && item.retention.expiresAt !== null
? `${t(RETENTION_LABEL.until)} ${new Date(item.retention.expiresAt).toLocaleDateString()}`
: t(RETENTION_LABEL[item.retention.mode]);
const action = (label: string, onClick: () => void, opts: { feature?: DrawerFeature; variant?: "outline" | "ghost" | "primary" | "danger"; off?: boolean } = {}): React.ReactElement => {
const missing = opts.feature !== undefined && unavailable.has(opts.feature);
return (
<Button
variant={opts.variant ?? "outline"}
disabled={(opts.off ?? disabled) || missing}
onClick={onClick}
className="min-h-11 w-full justify-center lg:min-h-9"
title={missing ? soon : undefined}
>
{label}
{missing && <span className="text-2xs font-normal text-text-faint">· {soon}</span>}
</Button>
);
};
const rows: Array<[string, React.ReactNode]> = [
[t({ en: "Type", es: "Tipo" }), `${t(mimeClassLabel(item.mimeClass))} · ${item.mimeType}`],
[t({ en: "Size", es: "Tamaño" }), formatBytes(item.sizeBytes)],
[t({ en: "Folder", es: "Carpeta" }), location],
[t({ en: "Added by", es: "Añadido por" }), item.senderId],
[t({ en: "Added", es: "Añadido" }), <time key="created" dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>],
];
return (
<aside
aria-label={t({ en: "File details", es: "Detalles del archivo" })}
className="fixed inset-x-0 bottom-0 z-40 flex max-h-[72dvh] flex-col rounded-t-xl border-t border-border bg-surface shadow-2xl lg:static lg:z-auto lg:max-h-none lg:w-80 lg:shrink-0 lg:rounded-none lg:border-l lg:border-t-0 lg:shadow-none"
>
<div className="flex items-start gap-3 border-b border-border-subtle p-4">
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<h2 className="break-all text-sm font-semibold">{item.name}</h2>
<div className="mt-1 flex flex-wrap gap-1">
<Badge tone={ORIGIN_TONE[item.origin.kind]}>{t(ORIGIN_LABEL[item.origin.kind])}</Badge>
<Badge tone={RETENTION_TONE[item.retention.mode]}>{retentionLabel}</Badge>
{item.retention.legalHold && <Badge tone="danger">{t(LEGAL_HOLD_LABEL)}</Badge>}
{personal && <Badge tone="accent">{t({ en: "Personal", es: "Personal" })}</Badge>}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label={t({ en: "Close details", es: "Cerrar detalles" })}
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-text-faint hover:bg-surface-2 hover:text-text lg:min-h-8 lg:min-w-8"
>
✕
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-thin">
<dl className="mb-4 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-2xs">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-text-faint">{label}</dt>
<dd className="min-w-0 break-all text-text-dim">{value}</dd>
</div>
))}
</dl>
<div className="grid grid-cols-2 gap-2">
{viewable && action(t({ en: "View", es: "Ver" }), onView, { variant: "primary" })}
{action(t({ en: "Download", es: "Descargar" }), onDownload, viewable ? {} : { variant: "primary" })}
{action(t({ en: "Share link", es: "Enlace" }), onShare)}
{hasActiveShare && action(t({ en: "Revoke link", es: "Revocar enlace" }), onRevoke, { variant: "ghost", off: busy || !live })}
{action(t({ en: "Move", es: "Mover" }), onMove, { feature: "move" })}
{action(t({ en: "Copy", es: "Copiar" }), onCopy, { feature: "copy" })}
{personal && action(t({ en: "Share to collective", es: "Compartir con colectivo" }), onShareToGroup, { feature: "shareToGroup" })}
{canRetention && action(t({ en: "Retention", es: "Retención" }), onRetention, { variant: "ghost", off: busy || !live })}
</div>
{canDelete && (
<div className="mt-4 border-t border-border-subtle pt-4">
{action(t({ en: "Delete…", es: "Borrar…" }), onDelete, { feature: "delete", variant: "danger", off: busy || !live || item.retention.legalHold })}
{item.retention.legalHold && (
<p className="mt-1.5 text-2xs text-text-faint">
{t({ en: "Legal hold blocks deletion until an admin releases it.", es: "La retención legal bloquea el borrado hasta que un admin la libere." })}
</p>
)}
</div>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,161 @@
import { api, ApiError } from "../../../lib/api-client";
import type { GroupFile, GroupFilePage } from "../types";
/**
* Wire calls for the files.* explorer: folders, move/copy, share-to-group,
* delete and the per-group member-delete setting. The folder/move/copy
* endpoints were landing in parallel with this screen, so every call here
* reports a 404 (or 405) as `"unavailable"` instead of throwing — the UI then
* shows a "coming soon" state for that one feature rather than an error.
*/
export interface Folder {
id: string;
name: string;
parentId: string | null;
groupId?: string | null;
personal: boolean;
}
/** Where the center pane is pointed: a group (root or folder) or the caller's personal space. */
export type FilesLocation =
| { kind: "group"; groupId: string; folderId: string | null }
| { kind: "personal"; folderId: string | null };
/** A library row as the explorer sees it: the served GroupFile plus the folder fields. */
export type ExplorerFile = GroupFile & { folderId?: string | null; personal?: boolean };
export type Maybe<T> = { ok: true; value: T } | { ok: false; unavailable: true };
export function isMissingEndpoint(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 405 || err.status === 501);
}
async function maybe<T>(call: () => Promise<T>): Promise<Maybe<T>> {
try {
return { ok: true, value: await call() };
} catch (err) {
if (isMissingEndpoint(err)) return { ok: false, unavailable: true };
throw err;
}
}
export function sameLocation(a: FilesLocation, b: FilesLocation): boolean {
if (a.kind !== b.kind || a.folderId !== b.folderId) return false;
return a.kind === "personal" || (b.kind === "group" && a.groupId === b.groupId);
}
export function locationKey(location: FilesLocation): string {
return location.kind === "group"
? `g:${location.groupId}:${location.folderId ?? ""}`
: `p:${location.folderId ?? ""}`;
}
export function listGroupFolders(groupId: string, signal?: AbortSignal): Promise<Maybe<Folder[]>> {
return maybe(() => api.get<Folder[]>("/files/folders", { scope: "group", groupId }, signal));
}
export function listPersonalFolders(signal?: AbortSignal): Promise<Maybe<Folder[]>> {
return maybe(() => api.get<Folder[]>("/files/folders", { scope: "personal" }, signal));
}
export function createFolder(name: string, location: FilesLocation): Promise<Maybe<Folder>> {
const scope = location.kind === "group" ? { groupId: location.groupId } : { personal: true };
const parent = location.folderId === null ? {} : { parentId: location.folderId };
return maybe(() => api.post<Folder>("/files/folders", { name, ...scope, ...parent }));
}
export function renameFolder(id: string, name: string): Promise<Maybe<Folder>> {
return maybe(() => api.patch<Folder>(`/files/folders/${encodeURIComponent(id)}`, { name }));
}
export function deleteFolder(id: string): Promise<Maybe<unknown>> {
return maybe(() => api.del<unknown>(`/files/folders/${encodeURIComponent(id)}`));
}
/** One page of the library at a location (the existing GroupFilePage shape). */
export function listFiles(
location: FilesLocation,
filters: { name: string; mimeClass: string; limit: number; cursor?: string },
signal?: AbortSignal,
): Promise<Maybe<GroupFilePage>> {
const scope = location.kind === "group" ? { groupId: location.groupId } : { scope: "personal" };
const folder = location.folderId === null ? {} : { folderId: location.folderId };
return maybe(() => api.get<GroupFilePage>("/files", { ...scope, ...folder, ...filters }, signal));
}
/** `folderId: null` moves the item back to its scope's root. */
export function moveFile(id: string, folderId: string | null): Promise<Maybe<unknown>> {
return maybe(() => api.post<unknown>(`/files/${encodeURIComponent(id)}/move`, { folderId }));
}
export type CopyTarget = { targetFolderId: string } | { targetGroupId: string };
export function copyFile(id: string, target: CopyTarget): Promise<Maybe<unknown>> {
return maybe(() => api.post<unknown>(`/files/${encodeURIComponent(id)}/copy`, target));
}
export function shareToGroup(id: string, groupId: string): Promise<Maybe<unknown>> {
return maybe(() => api.post<unknown>(`/files/${encodeURIComponent(id)}/share-to-group`, { groupId }));
}
/** DELETE /files/:id — a 403 means the delete guard refused (hold, retention, role). */
export function deleteFile(id: string): Promise<Maybe<unknown>> {
return maybe(() => api.del<unknown>(`/files/${encodeURIComponent(id)}`));
}
/**
* groups.member_delete_own for one group. The setting rides the groups
* settings API; when the endpoint or the field is absent the documented
* default is `true` (members may delete what they uploaded).
*/
export async function fetchMemberDeleteOwn(groupId: string, signal?: AbortSignal): Promise<boolean> {
const id = encodeURIComponent(groupId);
// Dedicated settings route first; until it lands, the group detail may carry the field.
for (const path of [`/groups/${id}/settings`, `/groups/${id}`]) {
try {
const body = await api.get<Record<string, unknown> | null>(path, undefined, signal);
const nested = body?.settings;
const settings = typeof nested === "object" && nested !== null ? (nested as Record<string, unknown>) : body;
const raw = settings?.member_delete_own ?? settings?.memberDeleteOwn ?? body?.member_delete_own ?? body?.memberDeleteOwn;
if (typeof raw === "boolean") return raw;
} catch {
// Missing route or no access: try the next source, then fall back to the default.
}
}
return true;
}
/** Uploads are stamped `@<username>:<server>`; the localpart identifies the caller's own files. */
export function isOwnFile(item: GroupFile, username: string | undefined): boolean {
if (username === undefined || username === "") return false;
const localpart = item.senderId.replace(/^@/, "").split(":")[0] ?? "";
return localpart.toLowerCase() === username.toLowerCase();
}
/** Flat folder list → children lookup keyed by parent id ("" = root). */
export function childrenByParent(folders: Folder[]): Map<string, Folder[]> {
const map = new Map<string, Folder[]>();
for (const folder of folders) {
const key = folder.parentId ?? "";
const list = map.get(key) ?? [];
list.push(folder);
map.set(key, list);
}
for (const list of map.values()) list.sort((a, b) => a.name.localeCompare(b.name));
return map;
}
/** Root → folder chain for breadcrumbs; stops on a broken or cyclic parent link. */
export function folderPath(folders: Folder[], folderId: string | null): Folder[] {
const byId = new Map(folders.map((folder) => [folder.id, folder]));
const path: Folder[] = [];
const seen = new Set<string>();
let current = folderId === null ? undefined : byId.get(folderId);
while (current !== undefined && !seen.has(current.id)) {
seen.add(current.id);
path.unshift(current);
current = current.parentId === null ? undefined : byId.get(current.parentId);
}
return path;
}

View file

@ -0,0 +1,237 @@
"use client";
import { useState, type ReactNode } from "react";
import { Button, Input } from "../../ui/primitives";
import { useFilesLang } from "../i18n";
import type { GroupSummary } from "../types";
import { FolderTree, type FolderState } from "./folder-tree";
import type { FilesLocation } from "./explorer-api";
/**
* Modal dialogs for the explorer: move/copy target picker, share-to-group
* group picker, folder rename and the delete confirmation. Same frame as
* ShareDialog/RetentionDialog. Every `onConfirm` resolves to `null` on success
* (the parent closes the dialog) or to an error line shown in place.
*/
function DialogFrame({ label, onClose, children }: { label: string; onClose: () => void; children: ReactNode }): React.ReactElement {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={label}
className="flex max-h-[88dvh] w-[460px] max-w-[92vw] flex-col rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{children}
</div>
</div>
);
}
function useSubmit(onConfirm: () => Promise<string | null>): { busy: boolean; error: string | null; submit: () => Promise<void> } {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(): Promise<void> {
setBusy(true);
setError(null);
const failure = await onConfirm();
setBusy(false);
if (failure !== null) setError(failure);
}
return { busy, error, submit };
}
const FOOTER_BUTTON = "min-h-11 lg:min-h-0";
export function TargetPickerDialog({
mode,
itemName,
groups,
groupFolders,
personalFolders,
showPersonal,
isDisabled,
onClose,
onConfirm,
}: {
mode: "move" | "copy";
itemName: string;
groups: GroupSummary[];
groupFolders: Record<string, FolderState>;
personalFolders: FolderState;
showPersonal: boolean;
isDisabled: (location: FilesLocation) => boolean;
onClose: () => void;
onConfirm: (target: FilesLocation) => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const [target, setTarget] = useState<FilesLocation | null>(null);
const { busy, error, submit } = useSubmit(() => (target === null ? Promise.resolve(null) : onConfirm(target)));
const title = mode === "move" ? t({ en: "Move to…", es: "Mover a…" }) : t({ en: "Copy to…", es: "Copiar a…" });
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={itemName}>{itemName}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{mode === "move"
? t({ en: "Pick a folder in the same space. Moving never changes who can see the file.", es: "Elige una carpeta del mismo espacio. Mover nunca cambia quién puede ver el archivo." })
: t({ en: "Pick a collective or folder. The copy is a new library entry; the original stays where it is.", es: "Elige un colectivo o carpeta. La copia es una entrada nueva; el original se queda donde está." })}
</p>
<div className="mt-3 min-h-0 flex-1 overflow-y-auto rounded-md border border-border-subtle bg-bg p-2 scrollbar-thin">
<FolderTree
groups={groups}
groupFolders={groupFolders}
personalFolders={personalFolders}
selected={target}
onSelect={setTarget}
showPersonal={showPersonal}
isDisabled={isDisabled}
/>
</div>
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" className={FOOTER_BUTTON} disabled={busy || target === null} onClick={() => void submit()}>
{busy
? t({ en: "Working…", es: "Procesando…" })
: mode === "move" ? t({ en: "Move here", es: "Mover aquí" }) : t({ en: "Copy here", es: "Copiar aquí" })}
</Button>
</div>
</DialogFrame>
);
}
export function GroupPickerDialog({ itemName, groups, onClose, onConfirm }: {
itemName: string;
groups: GroupSummary[];
onClose: () => void;
onConfirm: (groupId: string) => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const [groupId, setGroupId] = useState<string | null>(groups.length === 1 ? groups[0]?.id ?? null : null);
const { busy, error, submit } = useSubmit(() => (groupId === null ? Promise.resolve(null) : onConfirm(groupId)));
const title = t({ en: "Share to a collective", es: "Compartir con un colectivo" });
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={itemName}>{itemName}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Every member of the collective you pick will see this file in their group library. Your personal copy stays yours.",
es: "Todas las personas del colectivo que elijas verán este archivo en su biblioteca de grupo. Tu copia personal sigue siendo tuya.",
})}
</p>
{groups.length === 0 ? (
<p className="mt-3 text-xs text-text-faint">
{t({ en: "You are not in any collective yet.", es: "Aún no estás en ningún colectivo." })}
</p>
) : (
<fieldset className="mt-3 min-h-0 flex-1 space-y-1 overflow-y-auto scrollbar-thin">
<legend className="sr-only">{t({ en: "Collective", es: "Colectivo" })}</legend>
{groups.map((group) => (
<label
key={group.id}
className={`flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs transition-colors duration-150 ${
groupId === group.id ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
<input
type="radio"
name="share-group"
checked={groupId === group.id}
onChange={() => setGroupId(group.id)}
className="h-4 w-4 accent-accent"
/>
<span className="truncate">{group.name}</span>
</label>
))}
</fieldset>
)}
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" className={FOOTER_BUTTON} disabled={busy || groupId === null} onClick={() => void submit()}>
{busy ? t({ en: "Sharing…", es: "Compartiendo…" }) : t({ en: "Share", es: "Compartir" })}
</Button>
</div>
</DialogFrame>
);
}
export function RenameFolderDialog({ current, onClose, onConfirm }: {
current: string;
onClose: () => void;
onConfirm: (name: string) => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const [name, setName] = useState(current);
const trimmed = name.trim();
const { busy, error, submit } = useSubmit(() => onConfirm(trimmed));
const title = t({ en: "Rename folder", es: "Renombrar carpeta" });
const canSave = trimmed !== "" && trimmed !== current;
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<label className="mt-3 block text-2xs text-text-faint">
{t({ en: "Name", es: "Nombre" })}
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && canSave) void submit(); }}
className="mt-1 min-h-11 lg:min-h-0"
/>
</label>
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" className={FOOTER_BUTTON} disabled={busy || !canSave} onClick={() => void submit()}>
{busy ? t({ en: "Saving…", es: "Guardando…" }) : t({ en: "Save", es: "Guardar" })}
</Button>
</div>
</DialogFrame>
);
}
/** Destructive confirm that names the exact thing being deleted. */
export function ConfirmDeleteDialog({ kind, name, onClose, onConfirm }: {
kind: "file" | "folder";
name: string;
onClose: () => void;
onConfirm: () => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const { busy, error, submit } = useSubmit(onConfirm);
const title = kind === "file" ? t({ en: "Delete file?", es: "¿Borrar archivo?" }) : t({ en: "Delete folder?", es: "¿Borrar carpeta?" });
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{kind === "file"
? t({ en: "This permanently deletes", es: "Esto borra para siempre" })
: t({ en: "This deletes the empty folder", es: "Esto borra la carpeta vacía" })}{" "}
<strong className="break-all font-semibold text-text">“{name}”</strong>
{kind === "file"
? t({
en: " for everyone who can see it, and ends any share links to it. This cannot be undone.",
es: " para todas las personas que lo ven, y termina sus enlaces compartidos. No se puede deshacer.",
})
: "."}
</p>
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="danger" className={FOOTER_BUTTON} disabled={busy} onClick={() => void submit()}>
{busy ? t({ en: "Deleting…", es: "Borrando…" }) : t({ en: "Delete", es: "Borrar" })}
</Button>
</div>
</DialogFrame>
);
}

View file

@ -0,0 +1,786 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "../../ui/primitives";
import { OfflineBadge } from "../../ui/offline-badge";
import { EmptyState } from "../../brand/empty-state";
import { Loading } from "../../brand/loading";
import { Logo } from "../../brand/logo";
import { SessionStatus } from "../../auth/auth-gate";
import { api, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { downloadFileDirect, isDirectObject, saveBytes } from "../../../lib/files-direct";
import { FileRow } from "../file-row";
import { MediaThumb } from "../media-thumb";
import { MediaViewer } from "../media-viewer";
import { MimeIcon } from "../mime-icon";
import { UploadDropzone } from "../upload-dropzone";
import { ShareDialog } from "../share-dialog";
import { RetentionDialog } from "../retention-dialog";
import { downloadGroupFile } from "../files-client";
import { useFilesLang } from "../i18n";
import { MIME_CLASS_LABEL } from "../labels";
import type { GroupFile, GroupSummary, MimeClass, RetentionInput, ShareCreated } from "../types";
import { FolderTree, type FolderState } from "./folder-tree";
import { DetailsDrawer, type DrawerFeature } from "./details-drawer";
import { ConfirmDeleteDialog, GroupPickerDialog, RenameFolderDialog, TargetPickerDialog } from "./explorer-dialogs";
import {
copyFile,
createFolder,
deleteFile,
deleteFolder,
fetchMemberDeleteOwn,
folderPath,
isOwnFile,
listFiles,
listGroupFolders,
listPersonalFolders,
locationKey,
moveFile,
renameFolder,
sameLocation,
shareToGroup,
type ExplorerFile,
type Folder,
type FilesLocation,
} from "./explorer-api";
/**
* Full-screen files experience (files.* hostname). Left rail: folder tree —
* group folders per collective + the caller's personal space. Center: the
* existing upload dropzone, grid/list and media viewer, unchanged. Right (or
* bottom sheet on mobile): details drawer with share, retention, download,
* view, copy, move, share-to-group and the guarded delete.
*
* Folder/move/copy/share-to-group endpoints may still be landing: each one
* that 404s flips its feature into a "coming soon" state instead of erroring.
*/
const PAGE_LIMIT = 50;
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
const identityGroups = (groups: GroupSummary[]): GroupSummary[] => groups;
const isMedia = (item: GroupFile): boolean => item.mimeClass === "image" || item.mimeClass === "video";
type Dialog =
| { kind: "share"; item: ExplorerFile }
| { kind: "retention"; item: ExplorerFile }
| { kind: "move" | "copy"; item: ExplorerFile }
| { kind: "shareToGroup"; item: ExplorerFile }
| { kind: "deleteFile"; item: ExplorerFile }
| { kind: "renameFolder"; folder: Folder }
| { kind: "deleteFolder"; folder: Folder; location: FilesLocation };
export function FilesExplorer(): React.ReactElement {
const { t, lang, setLang } = useFilesLang();
const { status: authStatus, can, session } = useAuth();
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const authed = authStatus === "authenticated";
const isAdmin = can("workspace.members.manage") || session?.role === "Admin" || session?.role === "Owner";
const username = session?.user.username;
// ── folder tree ────────────────────────────────────────────────────────
const [groupFolders, setGroupFolders] = useState<Record<string, FolderState>>({});
const [personalFolders, setPersonalFolders] = useState<FolderState>(undefined);
const [memberDeleteOwn, setMemberDeleteOwn] = useState<Record<string, boolean>>({});
const [folderNonce, setFolderNonce] = useState(0);
const groupIds = useMemo(() => groups.data.map((group) => group.id).join(","), [groups.data]);
useEffect(() => {
if (!authed || groups.source !== "live") return;
const controller = new AbortController();
const ids = groupIds === "" ? [] : groupIds.split(",");
for (const id of ids) {
listGroupFolders(id, controller.signal)
.then((result) => setGroupFolders((current) => ({ ...current, [id]: result.ok ? result.value : "unavailable" })))
.catch(() => { if (!controller.signal.aborted) setGroupFolders((current) => ({ ...current, [id]: "unavailable" })); });
void fetchMemberDeleteOwn(id, controller.signal).then((allowed) => {
if (!controller.signal.aborted) setMemberDeleteOwn((current) => ({ ...current, [id]: allowed }));
});
}
listPersonalFolders(controller.signal)
.then((result) => setPersonalFolders(result.ok ? result.value : "unavailable"))
.catch(() => { if (!controller.signal.aborted) setPersonalFolders("unavailable"); });
return () => controller.abort();
}, [authed, groups.source, groupIds, folderNonce]);
const foldersFor = useCallback((location: FilesLocation): Folder[] => {
const state = location.kind === "group" ? groupFolders[location.groupId] : personalFolders;
return Array.isArray(state) ? state : [];
}, [groupFolders, personalFolders]);
// ── where the center pane points ───────────────────────────────────────
const [picked, setPicked] = useState<FilesLocation | null>(null);
const location: FilesLocation | null = useMemo(() => {
if (picked !== null) {
if (picked.kind === "personal" || groups.data.some((group) => group.id === picked.groupId)) return picked;
}
const first = groups.data[0];
if (first !== undefined) return { kind: "group", groupId: first.id, folderId: null };
return groups.source === "loading" ? null : { kind: "personal", folderId: null };
}, [picked, groups.data, groups.source]);
const scopeName = useCallback((loc: FilesLocation): string => {
if (loc.kind === "personal") return t({ en: "My files", es: "Mis archivos" });
return groups.data.find((group) => group.id === loc.groupId)?.name ?? t({ en: "Collective", es: "Colectivo" });
}, [groups.data, t]);
const crumbs = location === null ? [] : folderPath(foldersFor(location), location.folderId);
const currentFolder = crumbs.at(-1) ?? null;
const locationLabel = location === null ? "" : [scopeName(location), ...crumbs.map((folder) => folder.name)].join(" / ");
// ── listing ────────────────────────────────────────────────────────────
const [items, setItems] = useState<ExplorerFile[]>([]);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null);
const [listUnavailable, setListUnavailable] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [nonce, setNonce] = useState(0);
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [view, setView] = useState<"list" | "grid">("grid");
const live = source === "live";
useEffect(() => {
const timer = window.setTimeout(() => setQ(qInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [qInput]);
const locKey = location === null ? "" : locationKey(location);
useEffect(() => {
if (authStatus === "loading" || location === null) return;
if (!authed) {
setItems([]);
setNextCursor(null);
setSource("sample");
return;
}
const controller = new AbortController();
setLoading(true);
setListUnavailable(false);
listFiles(location, { name: q, mimeClass, limit: PAGE_LIMIT }, controller.signal)
.then((result) => {
setSource("live");
setError(null);
if (!result.ok) {
setItems([]);
setNextCursor(null);
setListUnavailable(true);
return;
}
setItems(result.value.items);
setNextCursor(result.value.nextCursor);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setItems([]);
setNextCursor(null);
setSource("sample");
return;
}
setSource("live");
// The personal scope may be rejected as a validation error until the api lands it.
if (location.kind === "personal" && err instanceof ApiError && err.status === 400) {
setItems([]);
setListUnavailable(true);
return;
}
setError(errorMessage(err));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
// locKey stands in for `location` (a fresh object each render).
}, [authStatus, authed, locKey, q, mimeClass, nonce]);
async function loadMore(): Promise<void> {
if (nextCursor === null || location === null) return;
setLoadingMore(true);
try {
const result = await listFiles(location, { name: q, mimeClass, limit: PAGE_LIMIT, cursor: nextCursor });
if (result.ok) {
setItems((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...result.value.items.filter((item) => !seen.has(item.id))];
});
setNextCursor(result.value.nextCursor);
}
} catch (err) {
setActionError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
// ── selection, actions, dialogs ────────────────────────────────────────
const [selectedId, setSelectedId] = useState<string | null>(null);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [shares, setShares] = useState<Record<string, ShareCreated>>({});
const [unavailable, setUnavailable] = useState<Set<DrawerFeature>>(new Set());
const [railOpen, setRailOpen] = useState(false);
const selected = items.find((item) => item.id === selectedId) ?? null;
const mediaItems = useMemo(() => items.filter(isMedia), [items]);
const personalScope = location?.kind === "personal";
const markUnavailable = (feature: DrawerFeature): string => {
setUnavailable((current) => new Set(current).add(feature));
return t({ en: "This action is coming soon — the server does not offer it yet.", es: "Esta acción llega pronto — el servidor aún no la ofrece." });
};
function navigate(next: FilesLocation): void {
setPicked(next);
setSelectedId(null);
setViewerIndex(null);
setRailOpen(false);
setActionError(null);
setNotice(null);
}
function canDelete(item: ExplorerFile): boolean {
if (isAdmin) return true;
if (personalScope || item.personal === true) return true;
// Mirrors the server guard: members may remove only their own web uploads, when the group allows it.
return item.origin.kind === "upload" && isOwnFile(item, username) && memberDeleteOwn[item.groupId] !== false;
}
const handleUploaded = useCallback((item: GroupFile): void => {
const folderId = location?.kind === "group" ? location.folderId : null;
setItems((current) => [{ ...item, folderId }, ...current.filter((existing) => existing.id !== item.id)]);
setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
// The upload lands at the collective root; file it into the open folder.
if (folderId !== null) {
moveFile(item.id, folderId)
.then((result) => {
if (!result.ok) setNotice(t({ en: "File uploaded to the collective root (folders are coming soon).", es: "Archivo subido a la raíz del colectivo (las carpetas llegan pronto)." }));
})
.catch((err: unknown) => setActionError(errorMessage(err)));
}
}, [location, t]);
async function handleDownload(item: GroupFile): Promise<void> {
setActionError(null);
try {
if (isDirectObject(item)) {
try {
saveBytes(await downloadFileDirect(item.id), item.name);
return;
} catch {
// Direct path is best-effort: fall back to streaming via the api once.
}
}
await downloadGroupFile(item.id, item.name);
} catch (err) {
setActionError(errorMessage(err));
}
}
async function revokeShare(item: GroupFile): Promise<void> {
const entry = shares[item.id];
if (entry === undefined) return;
const previous = shares;
setBusyId(item.id);
setActionError(null);
setShares((current) => {
const next = { ...current };
delete next[item.id];
return next;
});
try {
await api.del(`/files/shares/${encodeURIComponent(entry.id)}`);
setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." }));
} catch (err) {
setShares(previous);
setActionError(errorMessage(err));
} finally {
setBusyId(null);
}
}
function handleRetentionSaved(itemId: string, input: RetentionInput): void {
setItems((current) =>
current.map((item) =>
item.id === itemId
? { ...item, retention: { mode: input.mode, legalHold: input.legalHold ?? false, expiresAt: input.until ?? null } }
: item,
),
);
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
}
async function confirmMoveOrCopy(mode: "move" | "copy", item: ExplorerFile, target: FilesLocation): Promise<string | null> {
try {
if (mode === "move") {
const result = await moveFile(item.id, target.folderId);
if (!result.ok) return markUnavailable("move");
if (location !== null && !sameLocation(location, target)) {
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedId(null);
}
setNotice(t({ en: `Moved to ${scopeName(target)}.`, es: `Movido a ${scopeName(target)}.` }));
} else {
const body = target.folderId !== null
? { targetFolderId: target.folderId }
: target.kind === "group" ? { targetGroupId: target.groupId } : null;
if (body === null) return t({ en: "Pick a folder or a collective.", es: "Elige una carpeta o un colectivo." });
const result = await copyFile(item.id, body);
if (!result.ok) return markUnavailable("copy");
if (location !== null && sameLocation(location, target)) setNonce((n) => n + 1);
setNotice(t({ en: `Copied to ${scopeName(target)}.`, es: `Copiado a ${scopeName(target)}.` }));
}
setDialog(null);
return null;
} catch (err) {
return errorMessage(err);
}
}
async function confirmShareToGroup(item: ExplorerFile, groupId: string): Promise<string | null> {
try {
const result = await shareToGroup(item.id, groupId);
if (!result.ok) return markUnavailable("shareToGroup");
const name = groups.data.find((group) => group.id === groupId)?.name ?? "";
setNotice(t({ en: `Shared with ${name}.`, es: `Compartido con ${name}.` }));
setDialog(null);
return null;
} catch (err) {
return errorMessage(err);
}
}
async function confirmDeleteFile(item: ExplorerFile): Promise<string | null> {
try {
const result = await deleteFile(item.id);
if (!result.ok) return markUnavailable("delete");
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedId(null);
setViewerIndex(null);
setNotice(t({ en: `Deleted “${item.name}”.`, es: `Borrado «${item.name}».` }));
setDialog(null);
return null;
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
return t({
en: `Deletion refused: ${err.message}. Legal hold, retention or the collective's delete setting protects this file.`,
es: `Borrado rechazado: ${err.message}. La retención legal, la retención o la política del colectivo protegen este archivo.`,
});
}
return errorMessage(err);
}
}
async function handleCreateFolder(parent: FilesLocation, name: string): Promise<boolean> {
setActionError(null);
try {
const result = await createFolder(name, parent);
if (!result.ok) {
setActionError(t({ en: "Folders are coming soon — the server does not offer them yet.", es: "Las carpetas llegan pronto — el servidor aún no las ofrece." }));
return false;
}
const folder = result.value;
if (parent.kind === "group") {
setGroupFolders((current) => {
const list = current[parent.groupId];
return { ...current, [parent.groupId]: [...(Array.isArray(list) ? list : []), folder] };
});
} else {
setPersonalFolders((current) => [...(Array.isArray(current) ? current : []), folder]);
}
setNotice(t({ en: `Folder “${folder.name}” created.`, es: `Carpeta «${folder.name}» creada.` }));
return true;
} catch (err) {
setActionError(errorMessage(err));
return false;
}
}
async function confirmRenameFolder(folder: Folder, name: string): Promise<string | null> {
try {
const result = await renameFolder(folder.id, name);
if (!result.ok) return t({ en: "Renaming folders is coming soon.", es: "Renombrar carpetas llega pronto." });
setFolderNonce((n) => n + 1);
setDialog(null);
return null;
} catch (err) {
return errorMessage(err);
}
}
async function confirmDeleteFolder(folder: Folder, at: FilesLocation): Promise<string | null> {
try {
const result = await deleteFolder(folder.id);
if (!result.ok) return t({ en: "Deleting folders is coming soon.", es: "Borrar carpetas llega pronto." });
setFolderNonce((n) => n + 1);
setDialog(null);
navigate({ ...at, folderId: folder.parentId });
setNotice(t({ en: `Folder “${folder.name}” deleted.`, es: `Carpeta «${folder.name}» borrada.` }));
return null;
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
return t({ en: "This folder is not empty — move or delete what is inside first.", es: "Esta carpeta no está vacía — mueve o borra su contenido primero." });
}
return errorMessage(err);
}
}
// Link back to the main hub: app.<domain> when served from files.<domain>.
const [hubHref, setHubHref] = useState("/streams");
useEffect(() => {
const { hostname, protocol, port } = window.location;
if (hostname.startsWith("files.")) setHubHref(`${protocol}//app.${hostname.slice("files.".length)}${port === "" ? "" : `:${port}`}/streams`);
}, []);
const showList = source !== "loading";
const uploadGroupId = location?.kind === "group" ? location.groupId : "";
const rail = (
<FolderTree
groups={groups.data}
groupFolders={groupFolders}
personalFolders={personalFolders}
selected={location}
onSelect={navigate}
onCreate={handleCreateFolder}
/>
);
return (
<div className="flex h-dvh min-w-0 flex-col overflow-hidden bg-bg text-text">
{/* ── top bar ── */}
<header className="flex shrink-0 items-center gap-2 border-b border-border-subtle bg-surface px-3 py-2 lg:px-4">
<button
type="button"
onClick={() => setRailOpen((open) => !open)}
aria-expanded={railOpen}
aria-label={t({ en: "Folders", es: "Carpetas" })}
className="flex min-h-11 min-w-11 items-center justify-center rounded-md text-lg text-text-dim hover:bg-surface-2 lg:hidden"
>
☰
</button>
<Logo size={24} withText={false} />
<h1 className="text-sm font-semibold">{t({ en: "Files", es: "Archivos" })}</h1>
<OfflineBadge source={source} />
<div className="ml-auto flex items-center gap-3">
<span className="hidden text-2xs text-text-faint md:inline"><SessionStatus /></span>
<a href={hubHref} className="hidden min-h-11 items-center text-2xs font-medium text-text-dim hover:text-accent sm:flex lg:min-h-0">
{t({ en: "Open the hub ›", es: "Abrir el hub ›" })}
</a>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
{(["en", "es"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={`min-h-10 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 lg:min-h-8 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
{/* ── left rail ── */}
<aside className="hidden w-64 shrink-0 overflow-y-auto border-r border-border-subtle bg-surface p-3 scrollbar-thin lg:block">
{rail}
</aside>
{railOpen && (
<div className="fixed inset-0 z-40 bg-black/60 lg:hidden" onClick={() => setRailOpen(false)}>
<aside
className="h-full w-[82vw] max-w-xs overflow-y-auto border-r border-border bg-surface p-3 scrollbar-thin"
onClick={(e) => e.stopPropagation()}
>
{rail}
</aside>
</div>
)}
{/* ── center ── */}
<main className="min-w-0 flex-1 overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-4 flex flex-wrap items-center gap-2">
<nav aria-label={t({ en: "Breadcrumb", es: "Ruta" })} className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
{location !== null && (
<button
type="button"
onClick={() => navigate({ ...location, folderId: null })}
className="min-h-11 truncate font-semibold hover:text-accent lg:min-h-0"
>
{scopeName(location)}
</button>
)}
{location !== null && crumbs.map((folder) => (
<span key={folder.id} className="flex items-center gap-1">
<span aria-hidden className="text-text-faint">/</span>
<button
type="button"
onClick={() => navigate({ ...location, folderId: folder.id })}
aria-current={folder.id === location.folderId ? "page" : undefined}
className="min-h-11 truncate hover:text-accent lg:min-h-0"
>
{folder.name}
</button>
</span>
))}
</nav>
{currentFolder !== null && location !== null && (
<div className="ml-auto flex gap-1">
<Button size="sm" variant="ghost" className="min-h-11 lg:min-h-0" onClick={() => setDialog({ kind: "renameFolder", folder: currentFolder })}>
{t({ en: "Rename folder", es: "Renombrar carpeta" })}
</Button>
<Button size="sm" variant="ghost" className="min-h-11 lg:min-h-0" onClick={() => setDialog({ kind: "deleteFolder", folder: currentFolder, location })}>
{t({ en: "Delete folder", es: "Borrar carpeta" })}
</Button>
</div>
)}
</div>
{groups.error !== null && <p className="mb-3 text-sm text-danger">{groups.error}</p>}
{error !== null && <p className="mb-3 text-sm text-danger">{error}</p>}
{actionError !== null && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
{notice !== null && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
{location?.kind === "group" ? (
<UploadDropzone groupId={uploadGroupId} live={live} onUploaded={handleUploaded} />
) : location?.kind === "personal" ? (
<p className="mb-4 rounded-lg border border-border-subtle bg-surface p-3 text-2xs leading-relaxed text-text-faint">
{t({
en: "Your personal space: only you see these files. Use “Share to collective” on any of them to hand it to a group library.",
es: "Tu espacio personal: solo tú ves estos archivos. Usa «Compartir con colectivo» en cualquiera para pasarlo a una biblioteca de grupo.",
})}
</p>
) : null}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
type="search"
value={qInput}
onChange={(event) => setQInput(event.target.value)}
placeholder={t({ en: "Search files by name…", es: "Buscar archivos por nombre…" })}
aria-label={t({ en: "Search files", es: "Buscar archivos" })}
className="min-h-11 max-w-xs lg:min-h-0"
/>
<select
aria-label={t({ en: "MIME class", es: "Clase MIME" })}
value={mimeClass}
onChange={(event) => setMimeClass(event.target.value as "" | MimeClass)}
className="min-h-11 rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs lg:min-h-0"
>
<option value="">{t({ en: "All types", es: "Todos los tipos" })}</option>
{(Object.keys(MIME_CLASS_LABEL) as MimeClass[]).map((value) => (
<option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option>
))}
</select>
<div className="flex items-center gap-1" role="group" aria-label={t({ en: "View", es: "Vista" })}>
<Button size="sm" className="min-h-11 lg:min-h-0" variant={view === "grid" ? "primary" : "outline"} aria-pressed={view === "grid"} onClick={() => setView("grid")}>{t({ en: "Grid", es: "Cuadrícula" })}</Button>
<Button size="sm" className="min-h-11 lg:min-h-0" variant={view === "list" ? "primary" : "outline"} aria-pressed={view === "list"} onClick={() => setView("list")}>{t({ en: "List", es: "Lista" })}</Button>
</div>
{live && (
<Button size="sm" variant="ghost" className="min-h-11 lg:min-h-0" onClick={() => { setNonce((n) => n + 1); setFolderNonce((n) => n + 1); }} disabled={loading}>
{t({ en: "Refresh", es: "Actualizar" })}
</Button>
)}
</div>
{(source === "loading" || location === null) && (
<div className="flex items-center gap-2 text-xs text-text-faint">
<Loading size={16} label={t({ en: "Loading files", es: "Cargando archivos" })} />
{t({ en: "Loading files…", es: "Cargando archivos…" })}
</div>
)}
{showList && source === "sample" && (
<EmptyState
title={t({ en: "Offline", es: "Sin conexión" })}
hint={t({ en: "The API is unreachable — your files appear once it is back.", es: "La API no responde — tus archivos aparecerán cuando vuelva." })}
/>
)}
{showList && listUnavailable && (
<EmptyState
title={t({ en: "Coming soon", es: "Próximamente" })}
hint={t({ en: "This space is not available on the server yet. Group libraries keep working meanwhile.", es: "Este espacio aún no está disponible en el servidor. Las bibliotecas de grupo siguen funcionando." })}
/>
)}
{showList && live && loading && items.length > 0 && (
<div className="mb-3 flex items-center gap-2 text-2xs text-text-faint">
<Loading size={12} label={t({ en: "Refreshing", es: "Actualizando" })} />
{t({ en: "Refreshing…", es: "Actualizando…" })}
</div>
)}
{showList && live && !loading && !listUnavailable && error === null && items.length === 0 && (
<EmptyState
title={currentFolder !== null ? t({ en: "This folder is empty", es: "Esta carpeta está vacía" }) : t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
hint={
q !== "" || mimeClass !== ""
? t({ en: "No file matches these filters. Clear the search or filters.", es: "Ningún archivo coincide con estos filtros. Limpia la búsqueda o los filtros." })
: personalScope
? t({ en: "Files you keep for yourself show up here.", es: "Aquí aparecen los archivos que guardas para ti." })
: t({ en: "Drop a file above, or move one here from another folder.", es: "Suelta un archivo arriba, o mueve uno aquí desde otra carpeta." })
}
/>
)}
{showList && view === "grid" && items.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{items.map((item) => (
<div
key={item.id}
className={`rounded-lg ${item.id === selectedId ? "ring-2 ring-accent ring-offset-2 ring-offset-bg" : ""}`}
>
{isMedia(item) ? (
<MediaThumb item={item} onOpen={() => setSelectedId(item.id)} />
) : (
<button
type="button"
onClick={() => setSelectedId(item.id)}
className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border border-border bg-surface-2 p-2 text-center transition-colors hover:border-accent"
>
<MimeIcon mimeClass={item.mimeClass} size={28} className="text-accent" />
<span className="w-full truncate text-2xs text-text-dim">{item.name}</span>
</button>
)}
</div>
))}
</div>
)}
{showList && view === "list" && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<div key={item.id} className={`flex items-stretch gap-1.5 rounded-lg ${item.id === selectedId ? "ring-2 ring-accent" : ""}`}>
<div className="min-w-0 flex-1">
<FileRow
item={item}
live={live}
canRetention={isAdmin}
busy={busyId === item.id}
hasActiveShare={shares[item.id] !== undefined}
onDownload={() => void handleDownload(item)}
onShare={() => { setActionError(null); setDialog({ kind: "share", item }); }}
onRevoke={() => void revokeShare(item)}
onRetention={() => { setActionError(null); setDialog({ kind: "retention", item }); }}
/>
</div>
<button
type="button"
onClick={() => setSelectedId(item.id)}
aria-label={t({ en: `Details for ${item.name}`, es: `Detalles de ${item.name}` })}
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-lg border border-border-subtle bg-surface text-text-dim hover:border-accent hover:text-accent"
>
›
</button>
</div>
))}
</div>
)}
{live && nextCursor !== null && (
<div className="mt-4 flex justify-center">
<Button variant="outline" size="sm" className="min-h-11 lg:min-h-0" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? t({ en: "Loading…", es: "Cargando…" }) : t({ en: "Load more", es: "Cargar más" })}
</Button>
</div>
)}
</main>
{/* ── details drawer ── */}
{selected !== null && location !== null && (
<DetailsDrawer
item={selected}
live={live}
location={locationLabel}
personal={personalScope || selected.personal === true}
viewable={isMedia(selected)}
canRetention={isAdmin}
canDelete={canDelete(selected)}
hasActiveShare={shares[selected.id] !== undefined}
busy={busyId === selected.id}
unavailable={unavailable}
onClose={() => setSelectedId(null)}
onView={() => setViewerIndex(mediaItems.findIndex((media) => media.id === selected.id))}
onDownload={() => void handleDownload(selected)}
onShare={() => { setActionError(null); setDialog({ kind: "share", item: selected }); }}
onRevoke={() => void revokeShare(selected)}
onRetention={() => { setActionError(null); setDialog({ kind: "retention", item: selected }); }}
onMove={() => setDialog({ kind: "move", item: selected })}
onCopy={() => setDialog({ kind: "copy", item: selected })}
onShareToGroup={() => setDialog({ kind: "shareToGroup", item: selected })}
onDelete={() => setDialog({ kind: "deleteFile", item: selected })}
/>
)}
</div>
{viewerIndex !== null && mediaItems[viewerIndex] !== undefined && (
<MediaViewer items={mediaItems} index={viewerIndex} onClose={() => setViewerIndex(null)} onIndex={setViewerIndex} />
)}
{dialog?.kind === "share" && (
<ShareDialog
item={dialog.item}
onClose={() => setDialog(null)}
onCreated={(itemId, share) => setShares((current) => ({ ...current, [itemId]: share }))}
/>
)}
{dialog?.kind === "retention" && (
<RetentionDialog item={dialog.item} onClose={() => setDialog(null)} onSaved={handleRetentionSaved} />
)}
{(dialog?.kind === "move" || dialog?.kind === "copy") && location !== null && (() => {
const { item, kind } = dialog;
// Move stays inside the item's own space; copy may target any collective or folder.
const moveScope: FilesLocation = location.kind === "personal" ? location : { kind: "group", groupId: item.groupId, folderId: null };
const pickerGroups = kind === "move"
? (moveScope.kind === "group" ? groups.data.filter((group) => group.id === moveScope.groupId) : [])
: groups.data;
return (
<TargetPickerDialog
mode={kind}
itemName={item.name}
groups={pickerGroups}
groupFolders={groupFolders}
personalFolders={personalFolders}
showPersonal={kind === "copy" || moveScope.kind === "personal"}
isDisabled={(target) =>
(kind === "move" && sameLocation(target, location)) ||
(kind === "copy" && target.kind === "personal" && target.folderId === null)}
onClose={() => setDialog(null)}
onConfirm={(target) => confirmMoveOrCopy(kind, item, target)}
/>
);
})()}
{dialog?.kind === "shareToGroup" && (
<GroupPickerDialog
itemName={dialog.item.name}
groups={groups.data}
onClose={() => setDialog(null)}
onConfirm={(groupId) => confirmShareToGroup(dialog.item, groupId)}
/>
)}
{dialog?.kind === "deleteFile" && (
<ConfirmDeleteDialog kind="file" name={dialog.item.name} onClose={() => setDialog(null)} onConfirm={() => confirmDeleteFile(dialog.item)} />
)}
{dialog?.kind === "renameFolder" && (
<RenameFolderDialog current={dialog.folder.name} onClose={() => setDialog(null)} onConfirm={(name) => confirmRenameFolder(dialog.folder, name)} />
)}
{dialog?.kind === "deleteFolder" && (
<ConfirmDeleteDialog kind="folder" name={dialog.folder.name} onClose={() => setDialog(null)} onConfirm={() => confirmDeleteFolder(dialog.folder, dialog.location)} />
)}
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import { Input } from "../../ui/primitives";
import { Loading } from "../../brand/loading";
import { useFilesLang } from "../i18n";
import type { GroupSummary } from "../types";
import { childrenByParent, sameLocation, type Folder, type FilesLocation } from "./explorer-api";
/**
* Folder tree: one GROUP FOLDERS section per collective the caller belongs to,
* then a PERSONAL section. Used twice — as the explorer's left rail (with
* "+ new folder") and inside the move/copy picker (selection only).
*
* Folder lists arrive per scope: `undefined` = still loading, `"unavailable"`
* = the folders endpoint is not live yet (roots stay usable, folders show a
* "coming soon" note).
*/
export type FolderState = Folder[] | "unavailable" | undefined;
const ROW =
"flex min-h-11 w-full items-center gap-1.5 rounded-md px-2 text-left text-xs transition-colors duration-150 lg:min-h-8";
export function FolderTree({
groups,
groupFolders,
personalFolders,
selected,
onSelect,
onCreate,
showPersonal = true,
isDisabled,
}: {
groups: GroupSummary[];
groupFolders: Record<string, FolderState>;
personalFolders: FolderState;
selected: FilesLocation | null;
onSelect: (location: FilesLocation) => void;
/** Present only in the rail: creates a folder under `parent`. */
onCreate?: (parent: FilesLocation, name: string) => Promise<boolean>;
showPersonal?: boolean;
/** Picker mode: grey out targets that make no sense (e.g. the item's current folder). */
isDisabled?: (location: FilesLocation) => boolean;
}): React.ReactElement {
const { t } = useFilesLang();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [creatingAt, setCreatingAt] = useState<FilesLocation | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
function toggle(key: string): void {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
async function submitDraft(): Promise<void> {
if (creatingAt === null || onCreate === undefined) return;
const name = draft.trim();
if (name === "") {
setCreatingAt(null);
return;
}
setBusy(true);
const created = await onCreate(creatingAt, name);
setBusy(false);
if (created) {
setCreatingAt(null);
setDraft("");
}
}
function draftRow(parent: FilesLocation, depth: number): React.ReactElement | null {
if (creatingAt === null || !sameLocation(creatingAt, parent)) return null;
return (
<li style={{ paddingLeft: `${depth * 12 + 8}px` }} className="py-1 pr-1">
<Input
autoFocus
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submitDraft();
if (event.key === "Escape") setCreatingAt(null);
}}
onBlur={() => void submitDraft()}
placeholder={t({ en: "Folder name", es: "Nombre de la carpeta" })}
aria-label={t({ en: "New folder name", es: "Nombre de la nueva carpeta" })}
className="min-h-11 text-xs lg:min-h-8"
/>
</li>
);
}
function newFolderButton(parent: FilesLocation, available: boolean): React.ReactElement | null {
if (onCreate === undefined) return null;
return (
<button
type="button"
disabled={!available}
onClick={() => { setDraft(""); setCreatingAt(parent); }}
title={available ? undefined : t({ en: "Folders are coming soon", es: "Las carpetas llegan pronto" })}
aria-label={t({ en: "New folder", es: "Nueva carpeta" })}
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-sm text-text-faint transition-colors duration-150 hover:bg-surface-2 hover:text-accent disabled:opacity-40 lg:min-h-8 lg:min-w-8"
>
+
</button>
);
}
function nodeRow(location: FilesLocation, label: string, depth: number, expandKey: string | null, hasChildren: boolean, available: boolean): React.ReactElement {
const active = selected !== null && sameLocation(selected, location);
const disabled = isDisabled?.(location) ?? false;
const open = expandKey !== null && !collapsed.has(expandKey);
return (
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
{hasChildren && expandKey !== null ? (
<button
type="button"
onClick={() => toggle(expandKey)}
aria-expanded={open}
aria-label={open ? t({ en: `Collapse ${label}`, es: `Contraer ${label}` }) : t({ en: `Expand ${label}`, es: `Expandir ${label}` })}
className="flex min-h-11 w-7 shrink-0 items-center justify-center text-2xs text-text-faint hover:text-text lg:min-h-8"
>
{open ? "▾" : "▸"}
</button>
) : (
<span className="w-7 shrink-0" aria-hidden />
)}
<button
type="button"
disabled={disabled}
onClick={() => onSelect(location)}
aria-current={active ? "true" : undefined}
className={`${ROW} min-w-0 flex-1 ${
active ? "bg-accent-dim font-medium text-accent" : "text-text-dim hover:bg-surface-2 hover:text-text"
} disabled:opacity-40`}
>
<span aria-hidden>{location.folderId === null ? "▣" : "▢"}</span>
<span className="truncate">{label}</span>
</button>
{newFolderButton(location, available)}
</div>
);
}
function folderNodes(folders: Folder[], parentKey: string, base: (folderId: string) => FilesLocation, depth: number): React.ReactElement[] {
const children = childrenByParent(folders);
const render = (parentId: string, level: number): React.ReactElement[] =>
(children.get(parentId) ?? []).map((folder) => {
const location = base(folder.id);
const key = `${parentKey}/${folder.id}`;
const kids = children.get(folder.id) ?? [];
const showDraft = creatingAt !== null && sameLocation(creatingAt, location);
return (
<li key={folder.id}>
{nodeRow(location, folder.name, level, key, kids.length > 0 || showDraft, true)}
{!collapsed.has(key) && (
<ul>
{draftRow(location, level + 1)}
{render(folder.id, level + 1)}
</ul>
)}
</li>
);
});
return render("", depth);
}
function scopeBody(state: FolderState, root: FilesLocation, rootKey: string, base: (folderId: string) => FilesLocation): React.ReactElement | null {
if (collapsed.has(rootKey)) return null;
return (
<ul>
{draftRow(root, 1)}
{state === undefined ? (
<li className="flex items-center gap-2 py-1 pl-9 text-2xs text-text-faint">
<Loading size={10} label={t({ en: "Loading folders", es: "Cargando carpetas" })} />
</li>
) : state === "unavailable" ? (
onCreate !== undefined ? (
<li className="py-1 pl-9 text-2xs text-text-faint">{t({ en: "Folders coming soon", es: "Carpetas: próximamente" })}</li>
) : null
) : (
folderNodes(state, rootKey, base, 1)
)}
</ul>
);
}
return (
<nav aria-label={t({ en: "Folders", es: "Carpetas" })} className="space-y-4">
<section>
<h2 className="mb-1 px-2 text-2xs font-bold uppercase tracking-wider text-text-faint">
{t({ en: "Group folders", es: "Carpetas de grupo" })}
</h2>
{groups.length === 0 ? (
<p className="px-2 text-2xs leading-relaxed text-text-faint">
{t({ en: "Join or create a collective to get shared folders.", es: "Únete o crea un colectivo para tener carpetas compartidas." })}
</p>
) : (
<ul>
{groups.map((group) => {
const state = groupFolders[group.id];
const root: FilesLocation = { kind: "group", groupId: group.id, folderId: null };
const rootKey = `g:${group.id}`;
const hasChildren = Array.isArray(state) ? state.length > 0 : state === undefined || onCreate !== undefined;
return (
<li key={group.id}>
{nodeRow(root, group.name, 0, rootKey, hasChildren || (creatingAt !== null && sameLocation(creatingAt, root)), Array.isArray(state))}
{scopeBody(state, root, rootKey, (folderId) => ({ kind: "group", groupId: group.id, folderId }))}
</li>
);
})}
</ul>
)}
</section>
{showPersonal && (
<section>
<h2 className="mb-1 px-2 text-2xs font-bold uppercase tracking-wider text-text-faint">
{t({ en: "Personal", es: "Personal" })}
</h2>
<ul>
{(() => {
const root: FilesLocation = { kind: "personal", folderId: null };
const rootKey = "p";
const hasChildren = Array.isArray(personalFolders) ? personalFolders.length > 0 : personalFolders === undefined || onCreate !== undefined;
return (
<li>
{nodeRow(root, t({ en: "My files", es: "Mis archivos" }), 0, rootKey, hasChildren || (creatingAt !== null && sameLocation(creatingAt, root)), Array.isArray(personalFolders))}
{scopeBody(personalFolders, root, rootKey, (folderId) => ({ kind: "personal", folderId }))}
</li>
);
})()}
</ul>
</section>
)}
</nav>
);
}

View file

@ -9,6 +9,7 @@ import type { DraftRecord } from "../../lib/api-types";
import { LogoMark } from "../brand/logo";
import { NavIcon } from "../brand";
<<<<<<< HEAD
/** Grouped per the 2026-10 UX audit (16 flat items → 5 scannable sections). */
const NAV: { section: string; items: { href: string; label: string; icon: string; external?: boolean }[] }[] = [
{
@ -47,6 +48,26 @@ const NAV: { section: string; items: { href: string; label: string; icon: string
{ href: "/settings", label: "Settings", icon: "settings" },
],
},
=======
const NAV: { href: string; label: string; icon: string; external?: boolean }[] = [
{ href: "/streams", label: "Streams", icon: "video" },
{ href: "/control-room", label: "Control Room", icon: "video" },
{ href: "/compose", label: "Compose", icon: "compose" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/chat", label: "Chat", icon: "chat" },
{ href: "/files", label: "Files", icon: "files" },
{ href: "/archive", label: "Archive", icon: "files" },
{ href: "/inbox", label: "Inbox", icon: "inbox" },
{ href: "/outreach", label: "Outreach", icon: "team" },
{ href: "/approvals", label: "Approvals", icon: "approvals" },
{ href: "/video", label: "Video", icon: "video" },
{ href: "/analytics", label: "Analytics", icon: "analytics" },
{ href: "/connections", label: "Channels", icon: "connections" },
{ href: "/team", label: "Team", icon: "team" },
{ href: "/audit", label: "Audit log", icon: "audit" },
{ href: "/onboarding", label: "Onboarding", icon: "home" },
{ href: "/settings", label: "Settings", icon: "settings" },
>>>>>>> d310385 (feat: outreach contacts — track hand-written DMs per collective)
];
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;

View file

@ -0,0 +1,30 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { fillTemplate, parseImport, sentToday, type OutreachContact } from "./outreach-types";
test("parseImport reads quoted fields, skips header, blanks and comments", () => {
const { rows, errors } = parseImport([
"name,platform,handle,profileUrl,category,language",
"# afines",
"",
'"Stop the War, UK",Web,@STWuk,https://example.org/stw,paz,en',
].join("\n"));
assert.deepEqual(errors, []);
assert.deepEqual(rows, [{ name: "Stop the War, UK", platform: "web", handle: "@STWuk", profileUrl: "https://example.org/stw", category: "paz", language: "en" }]);
});
test("parseImport reports missing columns and non-https links by line", () => {
const { rows, errors } = parseImport("A,web,@a\nB,web,@b,http://example.org/b");
assert.deepEqual(rows, []);
assert.deepEqual(errors, ["line 1: needs name, platform, handle and profileUrl", "line 2: profileUrl must start with https://"]);
});
test("fillTemplate replaces every placeholder", () => {
assert.equal(fillTemplate("Hola {name} ({handle}), {name}", { name: "DAL", handle: "@dal" }), "Hola DAL (@dal), DAL");
});
test("sentToday counts first messages sent on the same local day", () => {
const now = new Date(2026, 9, 15, 18);
const at = (d: Date | null) => ({ sentAt: d === null ? null : d.toISOString() }) as OutreachContact;
assert.equal(sentToday([at(new Date(2026, 9, 15, 9)), at(new Date(2026, 9, 14, 23)), at(null)], now), 1);
});

View file

@ -0,0 +1,93 @@
/** Mirrors apps/api/src/outreach/outreach.service.ts (OutreachContact). */
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
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 OutreachImportRow {
name: string;
platform: string;
handle: string;
profileUrl: string;
category?: string;
language?: string;
}
/** First messages per day above which platforms start flagging an account. */
export const OUTREACH_DAILY_SOFT_CAP = 20;
export const IMPORT_COLUMNS = ["name", "platform", "handle", "profileUrl", "category", "language"] as const;
/**
* Parse pasted CSV (one contact per line, IMPORT_COLUMNS order, optional
* header). Double quotes wrap fields that contain commas. Blank lines and
* lines starting with # are skipped. Returns rows plus per-line errors.
*/
export function parseImport(text: string): { rows: OutreachImportRow[]; errors: string[] } {
const rows: OutreachImportRow[] = [];
const errors: string[] = [];
text.split(/\r?\n/).forEach((line, index) => {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) return;
const cells = splitCsvLine(trimmed);
if (index === 0 && cells[0]?.toLowerCase() === "name") return;
const [name, platform, handle, profileUrl, category, language] = cells;
if (!name || !platform || !handle || !profileUrl) {
errors.push(`line ${index + 1}: needs name, platform, handle and profileUrl`);
return;
}
if (!profileUrl.startsWith("https://")) {
errors.push(`line ${index + 1}: profileUrl must start with https://`);
return;
}
const row: OutreachImportRow = { name, platform: platform.toLowerCase(), handle, profileUrl };
if (category) row.category = category;
if (language) row.language = language;
rows.push(row);
});
return { rows, errors };
}
function splitCsvLine(line: string): string[] {
const cells: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
if (quoted) {
if (char === '"' && line[i + 1] === '"') { current += '"'; i += 1; }
else if (char === '"') quoted = false;
else current += char;
} else if (char === '"') quoted = true;
else if (char === ",") { cells.push(current.trim()); current = ""; }
else current += char;
}
cells.push(current.trim());
return cells;
}
/** Fill a template: {name} and {handle} are replaced per contact. */
export function fillTemplate(template: string, contact: Pick<OutreachContact, "name" | "handle">): string {
return template.replaceAll("{name}", contact.name).replaceAll("{handle}", contact.handle);
}
export function sentToday(contacts: OutreachContact[], now = new Date()): number {
const day = now.toDateString();
return contacts.filter((contact) => contact.sentAt !== null && new Date(contact.sentAt).toDateString() === day).length;
}

View file

@ -0,0 +1,27 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Hostname routing: files.<domain> serves the full-screen files explorer.
* The root of a files.* host is rewritten (URL stays "/") to /files-app;
* /streams (where sign-in and onboarding land) redirects back to "/" so a
* fresh sign-in on the files host returns to the explorer. Every other path —
* /onboarding, /login, /join — passes through so auth still works there. The
* landing page carries a client-side fallback for proxies that drop the Host
* header.
*/
export function middleware(request: NextRequest): NextResponse {
const host = (request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "").toLowerCase();
if (host.startsWith("files.")) {
const url = request.nextUrl.clone();
if (url.pathname === "/streams") {
// Absolute target from the public host, not the internal origin behind the tunnel.
const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
return NextResponse.redirect(new URL("/", `${proto}://${host}`));
}
url.pathname = "/files-app";
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = { matcher: ["/", "/streams"] };

View file

@ -1,6 +1,6 @@
# Connecting platform accounts
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, and Telegram. Other providers are not offered as connectable until a connector implements the same authorization surface.
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, Telegram, and Instagram. Other providers are not offered as connectable until a connector implements the same authorization surface.
## Configuration
@ -16,6 +16,7 @@ The API resolves authorization behavior through connector methods and manifests.
| `MASTODON_CLIENT_ID`, `MASTODON_CLIENT_SECRET` | Optional pre-registered Mastodon application credentials, used only for `MASTODON_INSTANCE_URL`. |
| `PEERTUBE_INSTANCE_URL` | Instance origin to which the configured PeerTube bridge client belongs. |
| `PEERTUBE_CLIENT_ID`, `PEERTUBE_CLIENT_SECRET` | Optional credentials for that instance. Otherwise the connector discovers the local client through `/api/v1/oauth-clients/local`. |
| `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET` | The Hub's Facebook app (App ID / App Secret from developers.facebook.com → App settings → Basic). One pair per deployment; Instagram is offered as connectable only when both are set. |
| `PEERTUBE_AUTHORIZE_PATH`, `PEERTUBE_TOKEN_PATH` | Required to enable a PeerTube redirect bridge. Both must be paths beginning with `/`, without a second leading slash, on the selected instance origin. |
Use HTTPS and the same browser site for the web and API (for example `app.example.org` and `api.example.org`), or proxy the API under the web origin. The callback binding cookie is HttpOnly, SameSite=Lax, and Secure in production. For development, consistently use `localhost` or consistently use `127.0.0.1` for both web and API; mixing the two breaks browser cookie delivery.
@ -30,6 +31,14 @@ Stock PeerTube documents local OAuth client discovery followed by a **password g
An instance administrator can enable a compatible OAuth bridge, then configure the two paths above. Its authorize endpoint must preserve `state`, register/validate the redirect URI, support `response_type=code` and S256 PKCE, and redirect back with a one-use code. Its token endpoint must validate `client_id`, `client_secret`, `redirect_uri`, `code`, and `code_verifier`, returning **actual PeerTube API** `access_token` and `refresh_token` values. Identity, refresh, and revoke then use PeerTube's native `/api/v1/users/me`, `/api/v1/users/token`, and `/api/v1/users/revoke-token` endpoints. These settings do not add an authorization-code grant to a stock instance, and an OIDC identity token alone is insufficient. The mock integration test proves this adapter contract; no third-party bridge is included in this change.
### Instagram
Instagram connects through Facebook Login against the Hub's own Facebook app. The App ID and App Secret identify the Hub, not a collective: they are read only from `INSTAGRAM_CLIENT_ID` / `INSTAGRAM_CLIENT_SECRET`, never entered on the connect form and never written to the vault or the pending store. In the Facebook app, add the product *Facebook Login for Business* and register the exact `CONNECTIONS_CALLBACK_URL` (default `${API_URL}/connections/callback`) as a Valid OAuth Redirect URI.
Each person connects their own account through Meta's consent dialog; the Hub keeps the resulting long-lived token encrypted per connection. Requirements per account: an Instagram **Business or Creator** account linked to a Facebook Page, and — while the app is in Development mode — a Tester role on the app. Grant exactly one Instagram account in the dialog: when several are granted the connect fails instead of guessing which one to publish as. Public use needs Meta App Review for `instagram_basic`, `instagram_content_publish` and `pages_show_list`.
Limitations: Instagram has no text-only posts and no binary upload — Meta downloads each JPEG from a public https URL. Media storage is client-side encrypted, so S3 presigned URLs cannot serve it; publishing images requires a short-lived plaintext media endpoint that is not part of this change. Until then publishing to Instagram fails with an explicit error. Stories are not publishable through the API; Meta caps API publishing at 25 posts per rolling 24 h per account.
### Telegram
Create a bot in BotFather and enter its bot token. The connector validates it with `getMe`, then stores the token through the same encrypted vault envelope as OAuth credentials. Optionally supply a chat/channel ID (`@channel` or a numeric chat ID); add the bot to the target and grant any necessary posting rights. Identity health confirms token validity, not permission to publish to every chat. Reconnect accepts a replacement bot token for the same bot and rotates the credential row in place. Telegram's Bot API cannot revoke its own bot token: Remove clears local authorization; revoke/rotate the token through BotFather when needed.

View file

@ -183,7 +183,6 @@ services:
<<: *appenv
MEDIA_WORKER_LANE: local
MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce
MEDIA_DEBUG_STDERR: "1"
volumes: [mediaworkerstate:/var/lib/media-worker]
depends_on:
postgres: { condition: service_healthy }

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
import { AuthError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import { InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
import {
AuthorizationRedirectRequired,
@ -19,7 +19,6 @@ interface StubCall {
init: RequestInit | undefined;
}
const API = "https://graph.facebook.com/v21.0";
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
const REDIRECT = "https://app.example.com/oauth/instagram/callback";
const IG_USER_ID = "17841400000000001";
@ -169,11 +168,12 @@ interface Harness {
credentials: InMemoryCredentials;
}
/** The Hub's Facebook app lives in the API environment, never in a connection. */
const APP_ENV = { INSTAGRAM_CLIENT_ID: "stub-client-id", INSTAGRAM_CLIENT_SECRET: "stub-client-secret" };
async function createHarness(seedToken = true): Promise<Harness> {
const stub = { calls: [], nextId: 100 };
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
await credentials.put(connection, "ig_user_id", IG_USER_ID);
if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN);
const connector = createInstagramConnector({
@ -181,7 +181,7 @@ async function createHarness(seedToken = true): Promise<Harness> {
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
credentials,
env: {},
env: APP_ENV,
});
return { stub, connector, credentials };
}
@ -190,8 +190,6 @@ const leg1: InstagramAuthorizationRequest = {
redirectUri: REDIRECT,
workspaceId: "ws-1",
state: "csrf-1",
// App credentials arrive as connection fields on the connect form.
fields: { clientId: "stub-client-id", clientSecret: "stub-client-secret" },
};
const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record<string, unknown> = {}): NormalizedPost => ({
@ -250,9 +248,9 @@ describe("instagram connector: authorize + identity", () => {
expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list");
expect(dialog.searchParams.get("state")).toBe("csrf-1");
expect(redirect.connectionId).toBe("instagram:ws-1");
// App credentials persist for leg 2.
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id");
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret");
// App credentials stay in the environment: nothing app-level reaches the vault.
await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).rejects.toThrow();
await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).rejects.toThrow();
expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0);
});
@ -299,7 +297,7 @@ describe("instagram connector: authorize + identity", () => {
});
it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => {
const fetchImpl: typeof fetch = async (input, init) => {
const fetchImpl: typeof fetch = async (input) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
@ -312,20 +310,56 @@ describe("instagram connector: authorize + identity", () => {
}
return graphError("no route", 100, 404);
};
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
const connector = createInstagramConnector({
fetchImpl,
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials,
env: {},
credentials: new InMemoryCredentials(),
env: APP_ENV,
});
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/);
});
it("refuses to pick one when several Instagram accounts were granted", async () => {
const fetchImpl: typeof fetch = async (input) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
if (url.pathname === "/oauth/access_token") {
return json({ access_token: url.searchParams.get("code") ? SHORT_TOKEN : LONG_TOKEN, token_type: "bearer" });
}
if (url.pathname === "/me") return json({ id: FB_USER_ID });
if (url.pathname === "/me/accounts") {
return json({
data: [
{ id: "page-a", instagram_business_account: { id: "17841400000000001", username: "acampada_a" } },
{ id: "page-b", instagram_business_account: { id: "17841400000000002", username: "acampada_b" } },
],
});
}
return graphError("no route", 100, 404);
};
const connector = createInstagramConnector({
fetchImpl,
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials: new InMemoryCredentials(),
env: APP_ENV,
});
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/2 Instagram professional accounts were granted/);
});
it("is not connectable until the Hub's Facebook app is configured", async () => {
const connector = createInstagramConnector({ credentials: new InMemoryCredentials(), env: {} });
expect(connector.manifest().oauth?.connectEnabled).toBe(false);
expect(connector.manifest().connectionFields.map((field) => field.name)).toEqual(["igUserId"]);
await expect(connector.authorize(leg1)).rejects.toThrow(/INSTAGRAM_CLIENT_ID/);
const configured = createInstagramConnector({ credentials: new InMemoryCredentials(), env: APP_ENV });
expect(configured.manifest().oauth?.connectEnabled).toBe(true);
});
it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => {
const { stub, connector } = await createHarness();
const identity = await connector.getIdentity(connection);
@ -374,8 +408,6 @@ describe("instagram connector: publish", () => {
it("uploads assets through loadAsset and captions the container", async () => {
const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
await credentials.put(connection, "ig_user_id", IG_USER_ID);
await credentials.put(connection, "access_token", LONG_TOKEN);
const connector = createInstagramConnector({
@ -415,8 +447,6 @@ describe("instagram connector: publish", () => {
const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials();
for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN],
] as const) {
@ -458,8 +488,6 @@ describe("instagram connector: publish", () => {
const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials();
for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN],
] as const) {
@ -499,7 +527,6 @@ describe("instagram connector: publish", () => {
it("rejects non-JPEG assets", async () => {
const { connector } = await createHarness();
(connector as unknown as { options?: unknown });
await expect(
connector.publish?.(connection, post("png", ["asset-png"], {
media: [],
@ -538,8 +565,6 @@ describe("instagram connector: deletePublication", () => {
const stub404Calls: StubCall[] = [];
const credentials = new InMemoryCredentials();
for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN],
] as const) {

View file

@ -10,7 +10,9 @@
* immediately exchanges it (grant_type=fb_exchange_token) for a long-lived
* (~60 day) token, then resolves the Instagram professional account through
* GET /me + GET /me/accounts (instagram_business_account). Stored credential
* keys: access_token, ig_user_id, client_id, client_secret.
* keys: access_token, ig_user_id. The Facebook app credentials identify the
* Hub, not a collective: they come only from INSTAGRAM_CLIENT_ID /
* INSTAGRAM_CLIENT_SECRET in the API environment and never enter the vault.
*
* Honest capability notes (verified against the Graph API):
* - Instagram has no text-only posts. publishText is declared only because
@ -100,8 +102,6 @@ const CAPABILITIES: ConnectorCapabilities = {
const SECRET = {
accessToken: "access_token",
igUserId: "ig_user_id",
clientId: "client_id",
clientSecret: "client_secret",
} as const;
export interface InstagramConnectorOptions {
@ -115,7 +115,7 @@ export interface InstagramConnectorOptions {
credentials?: CredentialAccessor;
/** Graph API version path segment. Default v21.0. */
apiVersion?: string;
/** Facebook app credentials fallback when not supplied per connection. */
/** INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET: the Hub's Facebook app. */
env?: Record<string, string | undefined>;
/** Resolve a media asset id to its metadata (needed by publish with media). */
loadAsset?: (assetId: string) => Promise<MediaAsset>;
@ -270,18 +270,6 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
displayName: "Instagram",
capabilities: CAPABILITIES,
connectionFields: [
{
name: "clientId",
label: "Facebook app ID (or INSTAGRAM_CLIENT_ID env)",
required: false,
secret: false,
},
{
name: "clientSecret",
label: "Facebook app secret (or INSTAGRAM_CLIENT_SECRET env)",
required: false,
secret: true,
},
{
name: "igUserId",
label: "Instagram professional account id (optional — resolved at connect)",
@ -296,14 +284,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
authorizeUrlTemplate: `${dialogBase}/dialog/oauth`,
scopes: SCOPES,
pkce: false,
connectEnabled: Boolean(str(env.INSTAGRAM_CLIENT_ID) && str(env.INSTAGRAM_CLIENT_SECRET)),
connectNote: "Instagram linking needs the Hub's Facebook app: set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET on the API.",
},
docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing",
};
function igUserIdOf(connection: ConnectorConnection): string {
return str(connection.fields.igUserId) ?? `vault:${connection.id}`;
}
/** Vault first; the connection field is the dev/test fallback. */
async function tokenOf(connection: ConnectorConnection): Promise<string> {
try {
@ -333,24 +319,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
}
}
/** Facebook app credentials: vault first, then connection fields, then env. */
void secretOrUndefined;
async function clientCredentials(
connection: ConnectorConnection,
): Promise<{ clientId: string; clientSecret: string }> {
const clientId =
(await secretOrUndefined(connection, SECRET.clientId)) ??
str(connection.fields.clientId) ??
str(env.INSTAGRAM_CLIENT_ID);
const clientSecret =
(await secretOrUndefined(connection, SECRET.clientSecret)) ??
str(connection.fields.clientSecret) ??
str(env.INSTAGRAM_CLIENT_SECRET);
/** The Hub's Facebook app credentials — server environment only, never per connection. */
function clientCredentials(): { clientId: string; clientSecret: string } {
const clientId = str(env.INSTAGRAM_CLIENT_ID);
const clientSecret = str(env.INSTAGRAM_CLIENT_SECRET);
if (clientId === undefined || clientSecret === undefined) {
throw new ConnectorError(
`${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`,
PROVIDER,
);
throw new ConnectorError(`${PROVIDER}: no Facebook app credentials — set INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, PROVIDER);
}
return { clientId, clientSecret };
}
@ -408,16 +382,27 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
/**
* GET /me validates the token (it is the Facebook user under Facebook
* Login), then GET /me/accounts resolves the first Page carrying an
* Login), then GET /me/accounts resolves the Page carrying an
* instagram_business_account — the IG professional account to publish as.
* Exactly one must be granted: picking one of several would publish as an
* account nobody chose, so the person re-links selecting a single one in
* Meta's dialog.
*/
async function fetchIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", {
fields: PAGE_FIELDS,
});
const linked = data.find((page) => page.instagram_business_account !== undefined)
?.instagram_business_account;
const granted = data
.map((page) => page.instagram_business_account)
.filter((account): account is NonNullable<FacebookPage["instagram_business_account"]> => account !== undefined);
if (granted.length > 1) {
throw new ConnectorError(
`${PROVIDER}: ${granted.length} Instagram professional accounts were granted — select exactly one in the Facebook dialog`,
PROVIDER,
);
}
const linked = granted[0];
if (linked === undefined) {
throw new ConnectorError(
`${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`,
@ -516,7 +501,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
provider: PROVIDER,
channelId: error.connectionId,
credentialId: { kind: "credential-ref", id: error.connectionId },
fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {},
fields: {},
createdAt: new Date(),
};
return { authorizationUrl: error.authorizationUrl, connection };
@ -531,7 +516,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
},
/**
* Leg 1 (no code): validate app credentials, store them in the vault and
* Leg 1 (no code): read the Hub's app credentials from the environment and
* throw AuthorizationRedirectRequired with the Facebook dialog URL.
* Leg 2 (providerMetadata.code): code → short-lived token → long-lived
* token (fb_exchange_token) → resolve the IG professional account via
@ -542,24 +527,17 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
const code = str(meta.code);
const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`;
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
const provided: Record<string, string> = {};
const fieldClientId = str(input.fields?.clientId);
const fieldClientSecret = str(input.fields?.clientSecret);
if (fieldClientId !== undefined) provided.clientId = fieldClientId;
if (fieldClientSecret !== undefined) provided.clientSecret = fieldClientSecret;
const connection: ConnectorConnection = {
id: connectionId,
workspaceId: input.workspaceId,
provider: PROVIDER,
channelId: str(meta.channelId) ?? connectionId,
credentialId,
fields: { ...provided },
fields: {},
createdAt: new Date(),
};
const client = await clientCredentials({ ...connection, fields: provided });
await credentials.put(connection, SECRET.clientId, client.clientId);
await credentials.put(connection, SECRET.clientSecret, client.clientSecret);
const client = clientCredentials();
if (code === undefined) {
const url = new URL(`${dialogBase}/dialog/oauth`);

View file

@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000;
export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = {
mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS },
telegram: { idempotency: "none" },
// Graph media_publish takes no idempotency key: an ambiguous send is never replayed.
instagram: { idempotency: "none" },
};
/** Unknown providers get the safe default: never replay an ambiguous send. */

3
pnpm-lock.yaml generated
View file

@ -50,6 +50,9 @@ importers:
'@opentelemetry/sdk-node':
specifier: ^0.222.0
version: 0.222.0(@opentelemetry/api@1.9.1)
'@tactical-media-broadcast-hub/connector-instagram':
specifier: workspace:*
version: link:../../packages/connector-instagram
'@tactical-media-broadcast-hub/connector-mastodon':
specifier: workspace:*
version: link:../../packages/connector-mastodon