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:
152
src/lib/content-collab.ts
Normal file
152
src/lib/content-collab.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user