feat: dual agency/client approval on calendar and assets

Private RRSS calendar with unified + per-network tabs, comments, and
double-check OK. Assets get review drawer with preview/comments. KPIs
gain date presets, North Star strip, channel matrix and per-channel dash.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 02:38:44 +02:00
parent 5460d5bd04
commit a1a3c26c30
11 changed files with 1801 additions and 374 deletions

152
src/lib/content-collab.ts Normal file
View File

@@ -0,0 +1,152 @@
/** Dual agency/client approval + comments for calendar posts and assets. */
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
export type ContentResourceType = 'calendar_post' | 'asset';
export type Party = 'agency' | 'client';
export type ApprovalRow = {
party: Party;
approved: boolean;
actor_email?: string | null;
created_at?: string;
note?: string | null;
};
export type CommentRow = {
id: string;
body: string;
author_email?: string | null;
author_party: Party;
created_at: string;
};
export function partyFromUser(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
} | null): Party {
return isAgencyRole(userRole(user)) ? 'agency' : 'client';
}
export function dualStatus(approvals: ApprovalRow[]): {
agency: boolean;
client: boolean;
both: boolean;
label: string;
} {
const agency = approvals.some((a) => a.party === 'agency' && a.approved);
const client = approvals.some((a) => a.party === 'client' && a.approved);
const both = agency && client;
let label = 'Pendiente';
if (both) label = 'Doble OK';
else if (agency && !client) label = 'OK agencia · falta cliente';
else if (client && !agency) label = 'OK cliente · falta agencia';
return { agency, client, both, label };
}
export async function listApprovals(
tenantId: string,
resourceType: ContentResourceType,
resourceIds: string[]
): Promise<Record<string, ApprovalRow[]>> {
const headers = serviceHeaders();
if (!headers || !resourceIds.length) return {};
const ids = resourceIds.map(encodeURIComponent).join(',');
const url =
`${supabaseUrl()}/rest/v1/content_approvals?tenant_id=eq.${encodeURIComponent(tenantId)}` +
`&resource_type=eq.${resourceType}&resource_id=in.(${ids})&select=*`;
const r = await fetch(url, { headers, cache: 'no-store' });
if (!r.ok) return {};
const rows = (await r.json()) as (ApprovalRow & { resource_id: string })[];
const map: Record<string, ApprovalRow[]> = {};
for (const row of rows) {
(map[row.resource_id] ||= []).push(row);
}
return map;
}
export async function listComments(
tenantId: string,
resourceType: ContentResourceType,
resourceId: string
): Promise<CommentRow[]> {
const headers = serviceHeaders();
if (!headers) return [];
const url =
`${supabaseUrl()}/rest/v1/content_comments?tenant_id=eq.${encodeURIComponent(tenantId)}` +
`&resource_type=eq.${resourceType}&resource_id=eq.${encodeURIComponent(resourceId)}` +
`&select=*&order=created_at.asc`;
const r = await fetch(url, { headers, cache: 'no-store' });
if (!r.ok) return [];
return (await r.json()) as CommentRow[];
}
export async function upsertApproval(opts: {
tenantId: string;
resourceType: ContentResourceType;
resourceId: string;
party: Party;
approved: boolean;
actorId?: string | null;
actorEmail?: string | null;
note?: string | null;
}): Promise<void> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const body = {
tenant_id: opts.tenantId,
resource_type: opts.resourceType,
resource_id: opts.resourceId,
party: opts.party,
approved: opts.approved,
actor_id: opts.actorId || null,
actor_email: opts.actorEmail || null,
note: opts.note || null,
};
const r = await fetch(
`${supabaseUrl()}/rest/v1/content_approvals?on_conflict=tenant_id,resource_type,resource_id,party`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(body),
}
);
if (!r.ok) throw new Error(await r.text());
}
export async function insertComment(opts: {
tenantId: string;
resourceType: ContentResourceType;
resourceId: string;
body: string;
authorId?: string | null;
authorEmail?: string | null;
authorParty: Party;
}): Promise<CommentRow> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/content_comments`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
tenant_id: opts.tenantId,
resource_type: opts.resourceType,
resource_id: opts.resourceId,
body: opts.body,
author_id: opts.authorId || null,
author_email: opts.authorEmail || null,
author_party: opts.authorParty,
}),
});
if (!r.ok) throw new Error(await r.text());
const rows = await r.json();
return Array.isArray(rows) ? rows[0] : rows;
}