merge: adapt/T-HUB features — Instagram connector, outreach contacts, folders schema, media RLS force
This commit is contained in:
commit
434e490f28
40 changed files with 3236 additions and 86 deletions
11
apps/api/migrations/042_media_assets_rls.sql
Normal file
11
apps/api/migrations/042_media_assets_rls.sql
Normal 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);
|
||||
51
apps/api/migrations/047_outreach.sql
Normal file
51
apps/api/migrations/047_outreach.sql
Normal 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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
141
apps/api/src/outreach/outreach.controller.ts
Normal file
141
apps/api/src/outreach/outreach.controller.ts
Normal 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;
|
||||
}
|
||||
13
apps/api/src/outreach/outreach.module.ts
Normal file
13
apps/api/src/outreach/outreach.module.ts
Normal 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 {}
|
||||
229
apps/api/src/outreach/outreach.service.ts
Normal file
229
apps/api/src/outreach/outreach.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
136
apps/api/src/outreach/outreach.test.ts
Normal file
136
apps/api/src/outreach/outreach.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
apps/media-worker/scripts/install-windows-worker.ps1
Normal file
61
apps/media-worker/scripts/install-windows-worker.ps1
Normal 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')"
|
||||
116
apps/media-worker/scripts/test-windows-worker.md
Normal file
116
apps/media-worker/scripts/test-windows-worker.md
Normal 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.
|
||||
185
apps/media-worker/scripts/windows-transcode-worker.py
Normal file
185
apps/media-worker/scripts/windows-transcode-worker.py
Normal 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())
|
||||
|
|
@ -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:^",
|
||||
|
|
|
|||
9
apps/web/src/app/(files-host)/files-app/page.tsx
Normal file
9
apps/web/src/app/(files-host)/files-app/page.tsx
Normal 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 />;
|
||||
}
|
||||
22
apps/web/src/app/(files-host)/layout.tsx
Normal file
22
apps/web/src/app/(files-host)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] }>();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
325
apps/web/src/app/(main)/outreach/page.tsx
Normal file
325
apps/web/src/app/(main)/outreach/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
155
apps/web/src/components/files/explorer/details-drawer.tsx
Normal file
155
apps/web/src/components/files/explorer/details-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/web/src/components/files/explorer/explorer-api.ts
Normal file
161
apps/web/src/components/files/explorer/explorer-api.ts
Normal 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;
|
||||
}
|
||||
237
apps/web/src/components/files/explorer/explorer-dialogs.tsx
Normal file
237
apps/web/src/components/files/explorer/explorer-dialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
786
apps/web/src/components/files/explorer/files-explorer.tsx
Normal file
786
apps/web/src/components/files/explorer/files-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
apps/web/src/components/files/explorer/folder-tree.tsx
Normal file
243
apps/web/src/components/files/explorer/folder-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
30
apps/web/src/lib/outreach-types.test.ts
Normal file
30
apps/web/src/lib/outreach-types.test.ts
Normal 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);
|
||||
});
|
||||
93
apps/web/src/lib/outreach-types.ts
Normal file
93
apps/web/src/lib/outreach-types.ts
Normal 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;
|
||||
}
|
||||
27
apps/web/src/middleware.ts
Normal file
27
apps/web/src/middleware.ts
Normal 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"] };
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue