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

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
dualStatus,
insertComment,
listApprovals,
listComments,
partyFromUser,
upsertApproval,
} from '@/lib/content-collab';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const dynamic = 'force-dynamic';
async function gate() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return {
error: NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 }),
};
}
return { user, ctx, party: partyFromUser(user) };
}
export async function GET(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, party } = g as {
ctx: { tenantId: string };
party: 'agency' | 'client';
};
const ids = (req.nextUrl.searchParams.get('ids') || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const resourceId = req.nextUrl.searchParams.get('resourceId');
const approvals = await listApprovals(ctx.tenantId, 'asset', ids);
const dual: Record<string, ReturnType<typeof dualStatus>> = {};
for (const id of ids) dual[id] = dualStatus(approvals[id] || []);
let comments = null;
if (resourceId) {
comments = await listComments(ctx.tenantId, 'asset', resourceId);
}
return NextResponse.json({ dual, comments, party });
}
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { user, ctx, party } = g as {
user: { id: string; email?: string };
ctx: { tenantId: string };
party: 'agency' | 'client';
};
const body = await req.json().catch(() => ({}));
const assetId = String(body.assetId || '');
if (!assetId) {
return NextResponse.json({ error: 'assetId requerido' }, { status: 400 });
}
if (body.action === 'approve' || body.action === 'revoke') {
await upsertApproval({
tenantId: ctx.tenantId,
resourceType: 'asset',
resourceId: assetId,
party,
approved: body.action === 'approve',
actorId: user.id,
actorEmail: user.email || null,
});
const approvals = await listApprovals(ctx.tenantId, 'asset', [assetId]);
const dual = dualStatus(approvals[assetId] || []);
// Mirror to asset_files columns when both OK
const headers = serviceHeaders();
if (headers) {
const patch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (party === 'agency') {
patch.agency_approved_at =
body.action === 'approve' ? new Date().toISOString() : null;
patch.agency_approved_by = body.action === 'approve' ? user.id : null;
} else {
patch.client_approved_at =
body.action === 'approve' ? new Date().toISOString() : null;
patch.client_approved_by = body.action === 'approve' ? user.id : null;
}
if (dual.both) patch.review_status = 'validated';
else patch.review_status = 'in_review';
await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetId)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(patch),
}
);
}
return NextResponse.json({ ok: true, dual, party });
}
if (body.action === 'comment') {
const text = String(body.body || '').trim();
if (!text) {
return NextResponse.json({ error: 'Comentario vacío' }, { status: 400 });
}
const row = await insertComment({
tenantId: ctx.tenantId,
resourceType: 'asset',
resourceId: assetId,
body: text.slice(0, 4000),
authorId: user.id,
authorEmail: user.email || null,
authorParty: party,
});
return NextResponse.json({ ok: true, comment: row });
}
return NextResponse.json({ error: 'action inválida' }, { status: 400 });
}