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:
4Ventures
2026-10-16 04:15:51 +02:00
parent 3b57cdce53
commit 4a1abd3318
54 changed files with 6400 additions and 527 deletions

View File

@@ -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 });
}

View File

@@ -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.';
}
}

View File

@@ -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,
},

View 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 || [],
});
}

View File

@@ -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,
});

View 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,
});
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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,
},
});
}

View File

@@ -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 });
}

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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}
>

View File

@@ -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)}