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:
135
src/app/api/assets/collab/route.ts
Normal file
135
src/app/api/assets/collab/route.ts
Normal 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 });
|
||||
}
|
||||
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