From 83de3fb3aaf2c24e82b685155a109d77cf9cbb0e Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 7 Oct 2026 15:29:49 +0200 Subject: [PATCH] Fix calendar drag snap-back, IG filter, and default to 2026. Prefer the latest year in the archive, normalize Instagram casing so stories show under IG/FB, sync pipeline dates on move, and stop stale plan re-renders from undoing a successful drag. Co-authored-by: Cursor --- .../dokploy_ensure_pipeline_publish_cron.py | 72 +++--- src/app/api/calendar/move/route.ts | 211 +++++++++++++++--- src/app/dashboard/calendario/page.tsx | 33 ++- src/components/RrssArchiveYear.tsx | 4 +- src/components/RrssMonthView.tsx | 33 ++- 5 files changed, 281 insertions(+), 72 deletions(-) diff --git a/scripts/dokploy_ensure_pipeline_publish_cron.py b/scripts/dokploy_ensure_pipeline_publish_cron.py index a8cf384..7bc7c2b 100644 --- a/scripts/dokploy_ensure_pipeline_publish_cron.py +++ b/scripts/dokploy_ensure_pipeline_publish_cron.py @@ -15,7 +15,7 @@ BASE = "https://dokploy.4ventures.es" APP_ID = "NZS797t9Rnq9Pj4mHiii3" SCHEDULE_NAME = "pipeline-publish" CRON_EXPR = "*/5 * * * *" -# Public URL + env expansion via bash -lc so CRON_SECRET from app env is used. +# Runs inside the app container so $CRON_SECRET from Dokploy env is available. CMD = ( "bash -lc " "'curl -sS -H \"Authorization: Bearer $CRON_SECRET\" " @@ -71,39 +71,51 @@ def main() -> int: try { json = JSON.parse(text); } catch {} return { ok: r.ok, status: r.status, text: text.slice(0, 1200), json }; }; + const get = async (path) => { + const r = await fetch(path, { credentials: 'include' }); + const text = await r.text(); + let json = null; + try { json = JSON.parse(text); } catch {} + return { ok: r.ok, status: r.status, text: text.slice(0, 2000), json }; + }; - // Delete existing schedules with same name - for (const listPath of [ - '/api/schedule.all', - '/api/schedule.byApplicationId', - ]) { - const listed = await post(listPath, { applicationId: appId }); - const rows = Array.isArray(listed.json) - ? listed.json - : listed.json?.data || listed.json?.schedules || []; - if (Array.isArray(rows)) { - for (const row of rows) { - const n = row?.name || row?.scheduleName; - const id = row?.scheduleId || row?.id; - if (n === name && id) { - await post('/api/schedule.delete', { scheduleId: id }); - await post('/api/schedule.remove', { scheduleId: id }); - } - } - } + // List application schedules (Dokploy v0.30) + const listed = await get( + '/api/trpc/schedule.list?batch=1&input=' + + encodeURIComponent(JSON.stringify({ + '0': { json: { id: appId, scheduleType: 'application' } } + })) + ); + const rows = + listed.json?.[0]?.result?.data?.json || + listed.json?.[0]?.result?.data || + []; + const existing = Array.isArray(rows) + ? rows.filter((r) => (r?.name || '') === name) + : []; + + for (const row of existing) { + const sid = row.scheduleId || row.id; + if (!sid) continue; + await post('/api/trpc/schedule.delete?batch=1', { + '0': { json: { scheduleId: sid } }, + }); } - const created = await post('/api/schedule.create', { - applicationId: appId, - name, - cron, - cronExpression: cron, - command, - shellType: 'bash', - enabled: true, - scheduleType: 'application', + const created = await post('/api/trpc/schedule.create?batch=1', { + '0': { + json: { + name, + cronExpression: cron, + shellType: 'bash', + command, + applicationId: appId, + scheduleType: 'application', + enabled: true, + }, + }, }); - return { created }; + return { deleted: existing.length, created }; }""", { "appId": APP_ID, diff --git a/src/app/api/calendar/move/route.ts b/src/app/api/calendar/move/route.ts index 170e2f4..6455ebb 100644 --- a/src/app/api/calendar/move/route.ts +++ b/src/app/api/calendar/move/route.ts @@ -1,8 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant } from '@/lib/tenant-context'; -import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; import type { RrssPiece } from '@/components/RrssMonthView'; +import { emptyMonthPlan } from '@/lib/calendar-documents'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; @@ -30,6 +36,81 @@ function piecesOf(content: Record): RrssPiece[] { return Array.isArray(content.pieces) ? (content.pieces as RrssPiece[]) : []; } +function monthTitle(ym: string): string { + const labels = [ + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre', + ]; + const m = Number(ym.slice(5, 7)) - 1; + return `RRSS ${labels[m] || ym} ${ym.slice(0, 4)}`; +} + +async function patchDoc( + base: string, + headers: Record, + id: string, + plan: Record +) { + return fetch(`${base}/rest/v1/documents?id=eq.${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + content: plan, + updated_at: new Date().toISOString(), + }), + }); +} + +/** Keep creative_pipeline.scheduled_at in sync so overlays don't snap back. */ +async function syncPipelineSchedule( + base: string, + headers: Record, + tenantId: string, + pieceId: string, + date: string, + time?: string +) { + const hhmm = (time || '10:00').slice(0, 5); + const scheduledAt = new Date(`${date}T${hhmm}:00`).toISOString(); + const pipeId = pieceId.startsWith('pipe-') ? pieceId.slice(5) : null; + + const filters = pipeId + ? `id=eq.${encodeURIComponent(pipeId)}` + : `calendar_piece_id=eq.${encodeURIComponent(pieceId)}`; + + await fetch( + `${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( + tenantId + )}&${filters}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + scheduled_at: scheduledAt, + updated_at: new Date().toISOString(), + }), + } + ); +} + export async function PATCH(req: NextRequest) { const supabase = await createClient(); const { @@ -38,6 +119,13 @@ export async function PATCH(req: NextRequest) { if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const agency = isAgencyRole(userRole(user)); + if (!agency) { + return NextResponse.json( + { error: 'Solo la agencia puede mover piezas' }, + { status: 403 } + ); + } const ctx = await resolveActiveTenant(user); if (!ctx.tenantId || ctx.portfolioWide) { return NextResponse.json( @@ -90,8 +178,24 @@ export async function PATCH(req: NextRequest) { const ym = date.slice(0, 7); let targetDoc: PlanDoc | null = source?.doc || null; let targetPlan: Record | null = source?.plan || null; + const sourceMonth = source ? String(source.plan.month || '') : ''; - if (!targetDoc || (source && String(source.plan.month || '') !== ym)) { + // Same-document move when the piece already lives in the target month doc + // (also covers old imports where plan.month was missing). + if ( + source && + (sourceMonth === ym || + (!sourceMonth && + piecesOf(source.plan).some((p) => + String(p.date || '').startsWith(ym) + ))) + ) { + targetDoc = source.doc; + targetPlan = source.plan; + if (!targetPlan.month) targetPlan.month = ym; + } else if (!targetDoc || sourceMonth !== ym) { + targetDoc = null; + targetPlan = null; for (const doc of docs) { const plan = asObject(doc.content); if (!plan) continue; @@ -103,33 +207,71 @@ export async function PATCH(req: NextRequest) { } } + // Auto-create month doc when dragging into a month that has no calendar yet. if (!targetDoc || !targetPlan) { - return NextResponse.json( - { error: 'No hay un calendario guardado para ese mes.' }, - { status: 404 } - ); + const plan = emptyMonthPlan(ym, ctx.label || 'Cliente'); + const create = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + tenant_id: ctx.tenantId, + type: 'calendar', + title: monthTitle(ym), + content: plan, + }), + }); + if (!create.ok) { + return NextResponse.json( + { + error: + 'No hay calendario para ese mes y no se pudo crear uno nuevo.', + }, + { status: 404 } + ); + } + const created = (await create.json()) as PlanDoc[]; + targetDoc = Array.isArray(created) ? created[0] : created; + targetPlan = plan as unknown as Record; + if (!targetDoc?.id) { + return NextResponse.json( + { error: 'No hay un calendario guardado para ese mes.' }, + { status: 404 } + ); + } } const targetPieces = piecesOf(targetPlan); + const movedTime = + source?.plan + ? piecesOf(source.plan)[source.index]?.time + : body?.snapshot?.time; if (source && source.doc.id === targetDoc.id) { - targetPieces[source.index] = { ...targetPieces[source.index], date }; + targetPieces[source.index] = { + ...targetPieces[source.index], + date, + platform: String(targetPieces[source.index].platform || 'instagram') + .trim() + .toLowerCase(), + }; } else if (source) { - const moved = { ...piecesOf(source.plan)[source.index], date }; + const moved = { + ...piecesOf(source.plan)[source.index], + date, + platform: String( + piecesOf(source.plan)[source.index]?.platform || 'instagram' + ) + .trim() + .toLowerCase(), + }; piecesOf(source.plan).splice(source.index, 1); source.plan.pieces = piecesOf(source.plan); - const srcPatch = await fetch( - `${base}/rest/v1/documents?id=eq.${source.doc.id}`, - { - method: 'PATCH', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'return=minimal', - }, - body: JSON.stringify({ content: source.plan }), - } - ); + if (!source.plan.month && sourceMonth) source.plan.month = sourceMonth; + const srcPatch = await patchDoc(base, headers, source.doc.id, source.plan); if (!srcPatch.ok) { return NextResponse.json( { error: 'No se pudo actualizar el mes de origen.' }, @@ -138,7 +280,14 @@ export async function PATCH(req: NextRequest) { } targetPieces.push(moved); } else if (body?.snapshot) { - targetPieces.push({ ...body.snapshot, date, id: pieceId }); + targetPieces.push({ + ...body.snapshot, + date, + id: pieceId, + platform: String(body.snapshot.platform || 'instagram') + .trim() + .toLowerCase(), + }); } else { return NextResponse.json( { error: 'No se encontrĂ³ esa pieza en el calendario.' }, @@ -146,19 +295,12 @@ export async function PATCH(req: NextRequest) { ); } + targetPlan.month = ym; targetPlan.pieces = targetPieces.sort((a, b) => String(a.date).localeCompare(String(b.date)) ); - const patch = await fetch(`${base}/rest/v1/documents?id=eq.${targetDoc.id}`, { - method: 'PATCH', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'return=minimal', - }, - body: JSON.stringify({ content: targetPlan }), - }); + const patch = await patchDoc(base, headers, targetDoc.id, targetPlan); if (!patch.ok) { return NextResponse.json( { error: (await patch.text()).slice(0, 240) || 'No se pudo guardar.' }, @@ -166,5 +308,14 @@ export async function PATCH(req: NextRequest) { ); } + await syncPipelineSchedule( + base, + headers, + ctx.tenantId, + pieceId, + date, + movedTime + ); + return NextResponse.json({ ok: true, pieceId, date }); } diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 46ea092..00fd2f1 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -97,6 +97,11 @@ export default async function CalendarioPage({ if (!plan) continue; const blob = JSON.stringify(plan).toLowerCase(); if (!isAguaMiia && blob.includes('agua miia')) continue; + // Normalize platform casing so IG/FB filter matches Drive imports ("Instagram"). + plan.pieces = (plan.pieces || []).map((p) => ({ + ...p, + platform: String(p.platform || 'instagram').trim().toLowerCase(), + })); plans.push(plan); } if (!plans.length && isAguaMiia) { @@ -124,8 +129,16 @@ export default async function CalendarioPage({ plans.splice(0, plans.length, ...(merged as RrssMonthPlan[])); } - const monthParam = sp.month || plans[0]?.month; - const active = plans.find((p) => p.month === monthParam) || plans[0]; + // Prefer the latest year/month (e.g. 2026 over 2025) unless ?month= is set. + const sortedMonths = [...plans.map((p) => p.month).filter(Boolean)].sort(); + const latestMonth = sortedMonths[sortedMonths.length - 1] || null; + const monthParam = sp.month || latestMonth || plans[0]?.month; + const active = + plans.find((p) => p.month === monthParam) || + (latestMonth + ? plans.find((p) => p.month === latestMonth) + : undefined) || + plans[0]; // Portada = archivo anual estilo /rrss/ig-fb/ const view = sp.view || 'archive'; const initialPlatform = @@ -172,7 +185,21 @@ export default async function CalendarioPage({ // Archive of months (like /rrss/ig-fb/) if (view === 'archive') { - const year = Number((active?.month || '2026-10').slice(0, 4)) || 2026; + const years = [ + ...new Set( + plans + .map((p) => Number(String(p.month || '').slice(0, 4))) + .filter((y) => y >= 2000 && y <= 2100) + ), + ].sort((a, b) => a - b); + const requestedYear = Number(String(monthParam || '').slice(0, 4)); + const year = + (requestedYear >= 2000 && requestedYear <= 2100 + ? requestedYear + : null) || + years[years.length - 1] || + Number((active?.month || '2026-10').slice(0, 4)) || + 2026; return ( [p.month, p])); const years = Array.from( new Set(plans.map((p) => Number(p.month.slice(0, 4))).filter(Boolean)) - ).sort(); + ).sort((a, b) => b - a); if (!years.includes(year)) years.push(year); - years.sort(); + years.sort((a, b) => b - a); const legend = plans[0]?.legend || [ { label: 'Educativo / serie', color: '#2071cf' }, { label: 'Familia / bienestar', color: '#E08E2E' }, diff --git a/src/components/RrssMonthView.tsx b/src/components/RrssMonthView.tsx index 905606a..027ccbb 100644 --- a/src/components/RrssMonthView.tsx +++ b/src/components/RrssMonthView.tsx @@ -179,9 +179,22 @@ export default function RrssMonthView({ const draggingId = React.useRef(null); const suppressClick = React.useRef(false); + // Only re-hydrate from server when the month or piece dates actually change. + // Avoids snapping a successful drag back if the parent re-renders with a + // stale plan object (common on older month views after soft navigation). + const serverSig = useMemo( + () => + `${plan.month}:${(plan.pieces || []) + .map((p) => `${p.id}@${String(p.date || '').slice(0, 10)}`) + .join(',')}`, + [plan.month, plan.pieces] + ); + const lastServerSig = React.useRef(''); useEffect(() => { + if (serverSig === lastServerSig.current) return; + lastServerSig.current = serverSig; setLivePieces(plan.pieces || []); - }, [plan]); + }, [serverSig, plan.pieces]); const pieces = livePieces; @@ -192,10 +205,11 @@ export default function RrssMonthView({ const filtered = pieces.filter((p) => { if (platform === 'all') return true; + const pl = String(p.platform || '').toLowerCase(); if (platform === 'instagram') { - return p.platform === 'instagram' || p.platform === 'facebook'; + return pl === 'instagram' || pl === 'facebook'; } - return p.platform === platform; + return pl === platform; }); const ids = pieces.map((p) => p.id); @@ -641,16 +655,21 @@ export default function RrssMonthView({ } }} onClick={() => openDay(key, dayPieces[0])} - className={`min-h-[112px] rounded-lg border bg-white p-1.5 text-left cursor-pointer overflow-hidden ${ + className={`min-h-[128px] rounded-lg border bg-white p-1.5 text-left cursor-pointer ${ over ? 'border-brand-500 ring-2 ring-brand-200 bg-brand-50/40' : 'border-gray-200 hover:border-brand-400 hover:shadow-sm' }`} > -

- {day} +

+ {day} + {dayPieces.length > 1 ? ( + + {dayPieces.length} + + ) : null}

-
+
{dayPieces.map((p) => { const tone = chipTone(p); const kind =