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:
127
src/app/api/calendar/collab/route.ts
Normal file
127
src/app/api/calendar/collab/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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 { isAgencyRole, userRole } 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 para colaborar en el calendario.' },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
return { user, ctx, party: partyFromUser(user) };
|
||||
}
|
||||
|
||||
/** GET ?ids=a,b,c — approvals map; &resourceId=x — also comments */
|
||||
export async function GET(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
ctx: { tenantId: string };
|
||||
};
|
||||
|
||||
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!, 'calendar_post', 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!, 'calendar_post', resourceId);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
approvals,
|
||||
dual,
|
||||
comments,
|
||||
party: (g as { party: string }).party,
|
||||
isAgency: isAgencyRole(userRole((g as { user: unknown }).user as never)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST actions:
|
||||
* { action: 'approve' | 'revoke', postId, note? }
|
||||
* { action: 'comment', postId, body }
|
||||
*/
|
||||
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 postId = String(body.postId || '');
|
||||
if (!postId) {
|
||||
return NextResponse.json({ error: 'postId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'approve' || body.action === 'revoke') {
|
||||
await upsertApproval({
|
||||
tenantId: ctx.tenantId,
|
||||
resourceType: 'calendar_post',
|
||||
resourceId: postId,
|
||||
party,
|
||||
approved: body.action === 'approve',
|
||||
actorId: user.id,
|
||||
actorEmail: user.email || null,
|
||||
note: body.note ? String(body.note) : null,
|
||||
});
|
||||
const approvals = await listApprovals(ctx.tenantId, 'calendar_post', [postId]);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
dual: dualStatus(approvals[postId] || []),
|
||||
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: 'calendar_post',
|
||||
resourceId: postId,
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user