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>
136 lines
4.3 KiB
TypeScript
136 lines
4.3 KiB
TypeScript
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 });
|
|
}
|