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>
153 lines
4.6 KiB
TypeScript
153 lines
4.6 KiB
TypeScript
/** 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;
|
|
}
|