feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -10,8 +11,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
||||
if (userRole !== 'admin' && userRole !== 'team') {
|
||||
if (!isAgencyRole(resolveUserRole(user))) {
|
||||
return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function GET(req: NextRequest) {
|
||||
mapped = filterDriveEntriesForClientRoot(mapped);
|
||||
if (!mapped.length && !scopeWarning) {
|
||||
scopeWarning =
|
||||
'Aún no hay carpetas generadas por la app (p. ej. Pipeline-creatividades). El resto del Drive solo lo ve la agencia.';
|
||||
'Aún no hay carpetas generadas por la app (Creatividades-Ads / Creatividades-RRSS). El resto del Drive solo lo ve la agencia.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
seedBrandbook,
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import { diffFields, logChange } from '@/lib/change-history';
|
||||
import {
|
||||
buildRevertPatch,
|
||||
diffFields,
|
||||
logChange,
|
||||
} from '@/lib/change-history';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -286,6 +290,14 @@ export async function PUT(req: NextRequest) {
|
||||
book as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const revertPatch =
|
||||
!body.seed && !created
|
||||
? buildRevertPatch(
|
||||
previousBook as unknown as Record<string, unknown>,
|
||||
book as unknown as Record<string, unknown>
|
||||
)
|
||||
: {};
|
||||
|
||||
await logChange({
|
||||
tenantId,
|
||||
tenantName: label,
|
||||
@@ -306,6 +318,8 @@ export async function PUT(req: NextRequest) {
|
||||
: `Guardado Zona de marca · ${label}`,
|
||||
detail: {
|
||||
changes: changes.slice(0, 40),
|
||||
revert_patch: revertPatch,
|
||||
revertable: Object.keys(revertPatch).length > 0,
|
||||
seed: Boolean(body.seed),
|
||||
slug,
|
||||
},
|
||||
|
||||
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* GET — resolve LinkedIn strategy URL + summary for active tenant
|
||||
* (brandbook URL, known slug map, optional ?url= override).
|
||||
* Used by CalendarCreator / zoom to show associated strategy content.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import {
|
||||
BRANDBOOK_DOC_TYPE,
|
||||
parseBrandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import {
|
||||
loadLinkedInStrategyContext,
|
||||
resolveLinkedInStrategyUrl,
|
||||
} from '@/lib/linkedin-strategy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 45;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
|
||||
}
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const override = req.nextUrl.searchParams.get('url') || '';
|
||||
const postId = req.nextUrl.searchParams.get('post_id') || '';
|
||||
|
||||
let slug = 'cliente';
|
||||
const t = await restGet<{ slug?: string }[]>(
|
||||
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
|
||||
);
|
||||
slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
const bb = await restGet<{ content?: unknown }[]>(
|
||||
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
|
||||
);
|
||||
const brandbook = parseBrandbook(bb.data?.[0]?.content);
|
||||
|
||||
const url = resolveLinkedInStrategyUrl({
|
||||
brandbook,
|
||||
slug,
|
||||
overrideUrl: override,
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
url: null,
|
||||
brandbook_url: brandbook?.linkedin_strategy_url || null,
|
||||
posts: [],
|
||||
themes: brandbook?.linkedin_themes || [],
|
||||
formats: brandbook?.linkedin_formats || [],
|
||||
ctas: brandbook?.linkedin_cta || [],
|
||||
message: 'Sin URL de estrategia LinkedIn en brandbook ni mapa conocido.',
|
||||
});
|
||||
}
|
||||
|
||||
const ctxLi = await loadLinkedInStrategyContext({
|
||||
url,
|
||||
brandbook,
|
||||
mode: 'from_strategy',
|
||||
});
|
||||
|
||||
const posts = (ctxLi?.posts || []).map((p) => ({
|
||||
id: p.id,
|
||||
n: p.n,
|
||||
title: p.title,
|
||||
meta: p.meta,
|
||||
angle: p.angle,
|
||||
body_preview: (p.body || '').slice(0, 500),
|
||||
body: postId && p.id === postId ? p.body : undefined,
|
||||
}));
|
||||
|
||||
let selected = postId
|
||||
? ctxLi?.posts.find((p) => p.id === postId) || null
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
url,
|
||||
brandbook_url: brandbook?.linkedin_strategy_url || null,
|
||||
posts_count: ctxLi?.posts.length || 0,
|
||||
posts: posts.slice(0, 80),
|
||||
selected: selected
|
||||
? {
|
||||
id: selected.id,
|
||||
title: selected.title,
|
||||
meta: selected.meta,
|
||||
angle: selected.angle,
|
||||
body: selected.body,
|
||||
}
|
||||
: null,
|
||||
themes: brandbook?.linkedin_themes || [],
|
||||
formats: brandbook?.linkedin_formats || [],
|
||||
ctas: brandbook?.linkedin_cta || [],
|
||||
thought_leadership: brandbook?.thought_leadership || [],
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
} from '@/lib/pipeline-calendar';
|
||||
import type { PublishedNetworkPost } from '@/lib/calendar-strategy';
|
||||
import { normalizeApprovalStatus } from '@/lib/approval-status';
|
||||
import { normalizeGenOptions } from '@/lib/calendar-gen-options';
|
||||
import {
|
||||
collectLinkedInUsage,
|
||||
loadLinkedInStrategyContext,
|
||||
pickUnpublishedStrategyPosts,
|
||||
resolveLinkedInStrategyUrl,
|
||||
} from '@/lib/linkedin-strategy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
@@ -57,9 +64,10 @@ export async function POST(req: NextRequest) {
|
||||
.map((t) => t.trim().slice(0, 160))
|
||||
.slice(0, 40)
|
||||
: [];
|
||||
const genOptions = (body.gen_options || body.options || null) as
|
||||
const genOptionsRaw = (body.gen_options || body.options || null) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
const genOptions = normalizeGenOptions(genOptionsRaw || undefined);
|
||||
|
||||
let slug = 'cliente';
|
||||
const t = await restGet<{ slug?: string }[]>(
|
||||
@@ -129,6 +137,58 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
let linkedinStrategyUrl = '';
|
||||
let linkedinPrompt: string | undefined;
|
||||
let linkedinStrategyPosts:
|
||||
| import('@/lib/linkedin-strategy').LinkedInStrategyPost[]
|
||||
| undefined;
|
||||
|
||||
if (genOptions.linkedinPosts > 0 || genOptions.linkedinStrategyUrl) {
|
||||
linkedinStrategyUrl = resolveLinkedInStrategyUrl({
|
||||
brandbook,
|
||||
slug,
|
||||
overrideUrl: genOptions.linkedinStrategyUrl,
|
||||
});
|
||||
if (linkedinStrategyUrl) {
|
||||
const liCtx = await loadLinkedInStrategyContext({
|
||||
url: linkedinStrategyUrl,
|
||||
brandbook,
|
||||
mode: genOptions.linkedinMode,
|
||||
});
|
||||
if (liCtx) {
|
||||
linkedinStrategyPosts = liCtx.posts;
|
||||
const usage = collectLinkedInUsage({
|
||||
history,
|
||||
publishedPosts,
|
||||
avoidTitles,
|
||||
});
|
||||
const pick = pickUnpublishedStrategyPosts(
|
||||
liCtx.posts,
|
||||
usage,
|
||||
genOptions.linkedinPosts || liCtx.posts.length
|
||||
);
|
||||
linkedinPrompt = [
|
||||
liCtx.promptBlock,
|
||||
`ESTADO BACKLOG ESTRATEGIA: ${pick.publishedOrPlanned.length} ya publicados o planificados · ${pick.unpublished.length} pendientes.`,
|
||||
pick.publishedOrPlanned.length
|
||||
? `YA USADOS (no re-agendar): ${pick.publishedOrPlanned
|
||||
.slice(0, 40)
|
||||
.map((p) => p.id)
|
||||
.join(', ')}`
|
||||
: '',
|
||||
pick.unpublished.length
|
||||
? `PENDIENTES (priorizar en orden): ${pick.unpublished
|
||||
.slice(0, 24)
|
||||
.map((p) => `${p.id} — ${p.title}`)
|
||||
.join(' | ')}`
|
||||
: 'Backlog agotado → crear ideas nuevas en línea de marca.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await generateMonthPreview({
|
||||
ym: month,
|
||||
slug,
|
||||
@@ -137,7 +197,10 @@ export async function POST(req: NextRequest) {
|
||||
brandbook,
|
||||
publishedPosts,
|
||||
avoidTitles,
|
||||
genOptions: genOptions || undefined,
|
||||
genOptions,
|
||||
linkedinStrategyUrl: linkedinStrategyUrl || undefined,
|
||||
linkedinPrompt,
|
||||
linkedinStrategyPosts,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -147,6 +210,9 @@ export async function POST(req: NextRequest) {
|
||||
source: result.source,
|
||||
brand: slug,
|
||||
brandbook: !!brandbook,
|
||||
linkedin_strategy_url: linkedinStrategyUrl || null,
|
||||
linkedin_posts_from_strategy: linkedinStrategyPosts?.length || 0,
|
||||
linkedin_schedule: result.linkedinMeta || null,
|
||||
history_months: history.length,
|
||||
network_posts: publishedPosts.length,
|
||||
});
|
||||
|
||||
262
src/app/api/change-history/undo/route.ts
Normal file
262
src/app/api/change-history/undo/route.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* POST /api/change-history/undo
|
||||
* Body: { id: string }
|
||||
* Deshace un cambio reversible y registra el deshacer en el historial.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
isHistoryRowRevertable,
|
||||
logChange,
|
||||
type ChangeHistoryRow,
|
||||
} from '@/lib/change-history';
|
||||
import {
|
||||
BRANDBOOK_DOC_TITLE,
|
||||
BRANDBOOK_DOC_TYPE,
|
||||
parseBrandbook,
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function getAdmin() {
|
||||
const url = supabaseUrl();
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
return createAdminClient(url, key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
|
||||
function patchFromChanges(
|
||||
changes: { field?: string; from?: unknown }[]
|
||||
): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const c of changes) {
|
||||
if (!c?.field) continue;
|
||||
const from = c.from;
|
||||
if (typeof from === 'string' && from.endsWith('…')) {
|
||||
// Valor clipado — no fiable para deshacer
|
||||
continue;
|
||||
}
|
||||
patch[c.field] = from ?? null;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const body = (await req.json().catch(() => ({}))) as { id?: string };
|
||||
const id = String(body.id || '').trim();
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Sin service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { data: row, error: fetchErr } = await admin
|
||||
.from('change_history')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchErr || !row) {
|
||||
return NextResponse.json(
|
||||
{ error: fetchErr?.message || 'Cambio no encontrado' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const historyRow = row as ChangeHistoryRow;
|
||||
|
||||
// Autorización por tenant
|
||||
if (!isAgency) {
|
||||
const own = homeTenantId(user);
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const allowed = own || ctx.tenantId;
|
||||
if (!allowed || historyRow.tenant_id !== allowed) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHistoryRowRevertable(historyRow)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Este cambio no se puede deshacer (ya deshecho, seed/creación, o sin parche reversible).',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const detail = historyRow.detail || {};
|
||||
let revertPatch: Record<string, unknown> = {};
|
||||
if (
|
||||
detail.revert_patch &&
|
||||
typeof detail.revert_patch === 'object' &&
|
||||
!Array.isArray(detail.revert_patch)
|
||||
) {
|
||||
revertPatch = { ...(detail.revert_patch as Record<string, unknown>) };
|
||||
} else if (Array.isArray(detail.changes)) {
|
||||
revertPatch = patchFromChanges(
|
||||
detail.changes as { field?: string; from?: unknown }[]
|
||||
);
|
||||
}
|
||||
|
||||
if (!Object.keys(revertPatch).length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'No hay datos suficientes para deshacer (valores anteriores incompletos).',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (historyRow.entity_type !== 'brandbook') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'De momento solo se pueden deshacer cambios de Zona de marca (brandbook).',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = historyRow.tenant_id;
|
||||
const docId = historyRow.entity_id;
|
||||
if (!tenantId || !docId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Falta tenant o documento en el registro' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: doc, error: docErr } = await admin
|
||||
.from('documents')
|
||||
.select('id, content, type')
|
||||
.eq('id', docId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (docErr || !doc) {
|
||||
return NextResponse.json(
|
||||
{ error: docErr?.message || 'Documento brandbook no encontrado' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const current = parseBrandbook(doc.content);
|
||||
if (!current) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Brandbook ilegible' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const beforeUndo = { ...(current as unknown as Record<string, unknown>) };
|
||||
const restoredFields: string[] = [];
|
||||
const book = { ...current } as Brandbook & Record<string, unknown>;
|
||||
|
||||
for (const [field, value] of Object.entries(revertPatch)) {
|
||||
if (field.startsWith('_')) continue;
|
||||
if (value === null) {
|
||||
// Campo que no existía: vaciar tipos comunes
|
||||
const cur = book[field];
|
||||
if (Array.isArray(cur)) book[field] = [];
|
||||
else if (typeof cur === 'string') book[field] = '';
|
||||
else if (typeof cur === 'object' && cur) book[field] = value;
|
||||
else book[field] = value;
|
||||
} else {
|
||||
book[field] = value as never;
|
||||
}
|
||||
restoredFields.push(field);
|
||||
}
|
||||
|
||||
book.updated_at = new Date().toISOString();
|
||||
|
||||
const { error: patchErr } = await admin
|
||||
.from('documents')
|
||||
.update({
|
||||
content: book,
|
||||
title: BRANDBOOK_DOC_TITLE,
|
||||
updated_at: book.updated_at,
|
||||
type: BRANDBOOK_DOC_TYPE,
|
||||
})
|
||||
.eq('id', docId);
|
||||
|
||||
if (patchErr) {
|
||||
return NextResponse.json({ error: patchErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Marca el original como deshecho (sigue visible en historial)
|
||||
const markedDetail = {
|
||||
...detail,
|
||||
undone: true,
|
||||
undone_at: new Date().toISOString(),
|
||||
undone_by: user.email || user.id,
|
||||
};
|
||||
await admin
|
||||
.from('change_history')
|
||||
.update({ detail: markedDetail })
|
||||
.eq('id', id);
|
||||
|
||||
const label =
|
||||
historyRow.tenant_name ||
|
||||
(await resolveActiveTenant(user)).label ||
|
||||
'cliente';
|
||||
|
||||
await logChange({
|
||||
tenantId,
|
||||
tenantName: label,
|
||||
actor: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role,
|
||||
},
|
||||
entityType: 'brandbook',
|
||||
entityId: docId,
|
||||
action: 'undo',
|
||||
summary: `Deshacer · ${historyRow.summary}`.slice(0, 500),
|
||||
detail: {
|
||||
undoes: id,
|
||||
undoes_summary: historyRow.summary,
|
||||
undoes_at: historyRow.created_at,
|
||||
restored_fields: restoredFields,
|
||||
changes: restoredFields.map((f) => ({
|
||||
field: f,
|
||||
from: beforeUndo[f] ?? null,
|
||||
to: revertPatch[f] ?? null,
|
||||
})),
|
||||
revertable: false,
|
||||
note: 'El cambio original permanece en el historial marcado como deshecho.',
|
||||
},
|
||||
source: 'api/change-history/undo',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
undid: id,
|
||||
restored_fields: restoredFields,
|
||||
});
|
||||
}
|
||||
@@ -74,10 +74,16 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { data: tenant } = await db
|
||||
.from('tenants')
|
||||
.select('id, slug, name')
|
||||
.select('id, slug, name, canva_templates')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'rrss',
|
||||
});
|
||||
|
||||
// Recent pipeline for continuity
|
||||
const { data: recentRows } = await db
|
||||
.from('creative_pipeline')
|
||||
@@ -216,6 +222,7 @@ export async function POST(req: NextRequest) {
|
||||
brandbookPrompt,
|
||||
socialHistory,
|
||||
variantsRequested: Number(body.variants_requested || 2),
|
||||
canvaTemplates,
|
||||
});
|
||||
|
||||
const { data: gr, error: grErr } = await db
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
channelsForCalendarPlatform,
|
||||
normalizeChannels,
|
||||
parseScheduledAt,
|
||||
PUBLISH_STATUSES,
|
||||
@@ -227,13 +228,7 @@ export async function POST(req: NextRequest) {
|
||||
scheduledAt = scheduledAtFromPiece(cal);
|
||||
}
|
||||
if (!channels) {
|
||||
const plat = (cal.platform || '').toLowerCase();
|
||||
channels =
|
||||
plat.includes('linkedin')
|
||||
? ['instagram']
|
||||
: plat.includes('facebook')
|
||||
? ['facebook', 'instagram']
|
||||
: ['instagram', 'facebook'];
|
||||
channels = channelsForCalendarPlatform(cal.platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
|
||||
*
|
||||
* También espeja el archivo en Drive del cliente:
|
||||
* {carpeta-cliente}/Pipeline-creatividades_{slug}/YYYY/MM/DD-MM-YYYY_formato_hook[-N].ext
|
||||
* {carpeta-cliente}/Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { syncCreativeAssetToDrive } from '@/lib/creatives-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 90;
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
@@ -170,6 +172,8 @@ export async function POST(req: NextRequest) {
|
||||
patch.asset_final = true;
|
||||
}
|
||||
|
||||
let creative: Record<string, unknown> | null = null;
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||
@@ -188,21 +192,53 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({
|
||||
creative: Array.isArray(data) ? data[0] : data,
|
||||
media_url: mediaUrl,
|
||||
});
|
||||
creative = Array.isArray(data) ? data[0] : data;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
creative = data;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ creative: data, media_url: mediaUrl });
|
||||
const ext =
|
||||
safeName.split('.').pop()?.toLowerCase() ||
|
||||
(mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg');
|
||||
|
||||
const drive = await syncCreativeAssetToDrive({
|
||||
tenantId: ctx.tenantId,
|
||||
clientLabel: ctx.label || 'cliente',
|
||||
channel: 'ads',
|
||||
item: {
|
||||
id: creativeId,
|
||||
title: String(creative?.title || title),
|
||||
format: String(creative?.format || (mime.startsWith('video/') ? 'video' : 'image')),
|
||||
created_at: String(creative?.created_at || new Date().toISOString()),
|
||||
angle: creative?.angle ? String(creative.angle) : null,
|
||||
hook: creative?.hook ? String(creative.hook) : null,
|
||||
},
|
||||
buffer: buf,
|
||||
mimeType: mime,
|
||||
ext,
|
||||
slot: 'asset',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
creative,
|
||||
media_url: mediaUrl,
|
||||
drive: {
|
||||
ok: drive.ok,
|
||||
skipped: drive.skipped || false,
|
||||
path: drive.drivePath || null,
|
||||
fileName: drive.fileName || null,
|
||||
fileId: drive.driveFileId || null,
|
||||
warning: drive.warning || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseGenerationRequestIdFromNotes,
|
||||
stripGenerationRequestMarker,
|
||||
} from '@/lib/rrss-generation';
|
||||
import { backfillCreativeItemToDrive } from '@/lib/creatives-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -110,7 +111,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { data: item } = await db
|
||||
.from('creative_pipeline')
|
||||
.select('id, notes, caption, format')
|
||||
.select('id, notes, caption, format, title, scheduled_at, created_at')
|
||||
.eq('id', pipelineId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
@@ -236,5 +237,42 @@ export async function POST(req: NextRequest) {
|
||||
.eq('tenant_id', tenantId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, item: updated });
|
||||
// Mirror final assets into Creatividades-RRSS_{slug}/YYYY/MM/…
|
||||
let drive: { ok: boolean; detail?: string } | null = null;
|
||||
if (coverPath && !/^https?:\/\//i.test(coverPath)) {
|
||||
try {
|
||||
const { data: tenant } = await db
|
||||
.from('tenants')
|
||||
.select('slug,name')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
const bf = await backfillCreativeItemToDrive({
|
||||
tenantId,
|
||||
clientLabel: tenant?.slug || tenant?.name || 'cliente',
|
||||
channel: 'rrss',
|
||||
item: {
|
||||
id: pipelineId,
|
||||
title: updated.title || item.title,
|
||||
format: updated.format || item.format,
|
||||
caption: updated.caption,
|
||||
notes: updated.notes,
|
||||
scheduled_at: updated.scheduled_at || item.scheduled_at,
|
||||
created_at: updated.created_at || item.created_at,
|
||||
cover_path: updated.cover_path,
|
||||
slide_paths: updated.slide_paths,
|
||||
},
|
||||
});
|
||||
drive = {
|
||||
ok: bf.ok,
|
||||
detail: bf.results.map((x) => `${x.slot}:${x.detail}`).join(' | '),
|
||||
};
|
||||
} catch (e) {
|
||||
drive = {
|
||||
ok: false,
|
||||
detail: e instanceof Error ? e.message : 'drive sync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, item: updated, drive });
|
||||
}
|
||||
|
||||
@@ -38,18 +38,11 @@ export async function GET(
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = (client as any)?.canva_templates || {};
|
||||
if (
|
||||
client?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(
|
||||
(client as { canva_templates?: unknown })?.canva_templates,
|
||||
{ slug: client?.slug, channel: 'ads' }
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
@@ -65,7 +58,7 @@ export async function GET(
|
||||
meta_publish_status: client?.meta_publish_status || 'PAUSED',
|
||||
slack_channel_id: client?.slack_channel_id,
|
||||
canva_templates: canvaTemplates,
|
||||
drive_folder_id: (client as any)?.drive_folder_id || null,
|
||||
drive_folder_id: (client as { drive_folder_id?: string | null })?.drive_folder_id || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -186,9 +179,83 @@ export async function PATCH(
|
||||
});
|
||||
}
|
||||
|
||||
// Mirror final Ads asset → Creatividades-Ads_{slug}/YYYY/MM/{slug}_ads_…
|
||||
let drive: { ok: boolean; path?: string; warning?: string } | null = null;
|
||||
if (updates.asset_final || updates.asset_url || updates.asset_final === true) {
|
||||
try {
|
||||
const { syncCreativeAssetToDrive } = await import('@/lib/creatives-drive');
|
||||
const { ASSETS_BUCKET, serviceSupabase } = await import('@/lib/assets');
|
||||
const { data: tenant } = await adminClient
|
||||
.from('tenants')
|
||||
.select('slug,name')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
let buffer: Buffer | null = null;
|
||||
let mimeType = 'image/jpeg';
|
||||
let ext = 'jpg';
|
||||
const storagePath = updated.storage_path || creative.storage_path;
|
||||
const assetUrl = updated.asset_url || creative.asset_url;
|
||||
|
||||
if (storagePath && typeof storagePath === 'string' && !/^https?:\/\//i.test(storagePath)) {
|
||||
const svc = serviceSupabase();
|
||||
if (svc) {
|
||||
const dl = await svc.storage.from(ASSETS_BUCKET).download(storagePath);
|
||||
if (dl.data) {
|
||||
buffer = Buffer.from(await dl.data.arrayBuffer());
|
||||
mimeType = dl.data.type || mimeType;
|
||||
ext = storagePath.split('.').pop()?.toLowerCase() || ext;
|
||||
}
|
||||
}
|
||||
} else if (assetUrl && typeof assetUrl === 'string' && /^https?:\/\//i.test(assetUrl)) {
|
||||
const r = await fetch(assetUrl, { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
buffer = Buffer.from(await r.arrayBuffer());
|
||||
mimeType = r.headers.get('content-type') || mimeType;
|
||||
ext = mimeType.includes('png')
|
||||
? 'png'
|
||||
: mimeType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
const sync = await syncCreativeAssetToDrive({
|
||||
tenantId,
|
||||
clientLabel: tenant?.slug || tenant?.name || 'cliente',
|
||||
channel: 'ads',
|
||||
item: {
|
||||
id,
|
||||
title: updated.title || creative.title,
|
||||
format: updated.format || creative.format || 'image',
|
||||
created_at: updated.created_at || creative.created_at,
|
||||
angle: updated.angle || creative.angle,
|
||||
hook: updated.hook || creative.hook,
|
||||
},
|
||||
buffer,
|
||||
mimeType,
|
||||
ext,
|
||||
slot: 'asset',
|
||||
});
|
||||
drive = {
|
||||
ok: sync.ok,
|
||||
path: sync.drivePath,
|
||||
warning: sync.warning,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
drive = {
|
||||
ok: false,
|
||||
warning: e instanceof Error ? e.message : 'drive sync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: updated,
|
||||
drive,
|
||||
...updated,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,18 +114,11 @@ export async function POST(req: NextRequest) {
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const clientInfo = {
|
||||
id: tenant?.id || tenantId,
|
||||
@@ -227,7 +220,7 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
|
||||
const enriched = (rows || [])
|
||||
.filter((row: any) => {
|
||||
@@ -236,17 +229,10 @@ export async function GET(req: NextRequest) {
|
||||
})
|
||||
.map((row: any) => {
|
||||
const { tenant, ...rest } = row;
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const item: Record<string, any> = {
|
||||
...rest,
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
VIEW_TENANT_SLUG_COOKIE,
|
||||
clearedViewTenantCookieOpts,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export async function logoutAction() {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// Evita que el siguiente login herede "vista cliente" de otra sesión.
|
||||
const jar = await cookies();
|
||||
const opts = clearedViewTenantCookieOpts(
|
||||
process.env.NODE_ENV === 'production'
|
||||
);
|
||||
jar.set(VIEW_TENANT_COOKIE, '', opts);
|
||||
jar.set(VIEW_TENANT_SLUG_COOKIE, '', opts);
|
||||
jar.set(VIEW_TENANT_NAME_COOKIE, '', opts);
|
||||
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import AgencyPortfolioBoard from '@/components/AgencyPortfolioBoard';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -15,8 +16,7 @@ export default async function AgencyPage() {
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const role = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
||||
if (role !== 'admin' && role !== 'team') {
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
redirect('/dashboard/kpis');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { logoutAction } from '@/app/auth/actions';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
@@ -30,16 +31,26 @@ export default async function DashboardLayout({
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Sin usuario real no falsificamos identidad (antes salía usuario@4ventures.es).
|
||||
if (!user?.email) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const company =
|
||||
(user?.user_metadata?.company as string) ||
|
||||
(user?.app_metadata?.company as string) ||
|
||||
'4Ventures Central';
|
||||
const email = user?.email || 'usuario@4ventures.es';
|
||||
(user.user_metadata?.company as string) ||
|
||||
(user.app_metadata?.company as string) ||
|
||||
(isAgencyRole(role) ? '4Ventures' : 'Portal cliente');
|
||||
const email = user.email;
|
||||
const isAgency = isAgencyRole(role);
|
||||
const cookieStore = await cookies();
|
||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
// Cookies de zoom solo aplican a agencia (evita chrome cliente en ops).
|
||||
const viewName = isAgency
|
||||
? cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value
|
||||
: undefined;
|
||||
const viewId = isAgency
|
||||
? cookieStore.get(VIEW_TENANT_COOKIE)?.value || null
|
||||
: null;
|
||||
const ownId = homeTenantId(user);
|
||||
|
||||
let enabledModules: Record<DashboardModuleId, boolean> | null = null;
|
||||
@@ -94,9 +105,7 @@ export default async function DashboardLayout({
|
||||
isAgency={isAgency}
|
||||
viewName={viewName}
|
||||
enabledModules={enabledModules}
|
||||
clientLogoUrl={
|
||||
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
|
||||
}
|
||||
clientLogoUrl={!isAgency ? clientLogoUrl : null}
|
||||
brandLabel={brandLabel}
|
||||
logoutAction={logoutAction}
|
||||
>
|
||||
|
||||
@@ -68,7 +68,12 @@ function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
autoComplete="on"
|
||||
data-form-type="login"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||
Correo Electrónico
|
||||
@@ -79,10 +84,12 @@ function LoginForm() {
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ejemplo@cliente.com"
|
||||
placeholder="marketing@4ventures.es"
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,6 +105,8 @@ function LoginForm() {
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
||||
Reference in New Issue
Block a user