Add agency-only schedule fields and queue for pipeline creatives.

Approved pieces can be timed for IG/FB from the agency board; cron queues due jobs and calendar uses scheduled_at. Meta publish comes next.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 17:54:00 +02:00
parent 392af11060
commit 12ecde1b3b
9 changed files with 629 additions and 13 deletions

View File

@@ -12,6 +12,9 @@ export type PipelineCalendarRow = {
notes?: string | null;
created_at?: string | null;
publish_date?: string | null;
scheduled_at?: string | null;
channels?: string[] | null;
publish_status?: string | null;
external_ref?: string | null;
};
@@ -32,8 +35,11 @@ export function slidesOf(v: unknown): string[] {
return v.filter((x): x is string => typeof x === 'string');
}
/** Day a published pipeline piece belongs on the calendar. */
/** Day a pipeline piece belongs on the calendar. Prefer agency schedule. */
export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
if (it.scheduled_at && /^\d{4}-\d{2}-\d{2}/.test(it.scheduled_at)) {
return it.scheduled_at.slice(0, 10);
}
if (it.publish_date && /^\d{4}-\d{2}-\d{2}/.test(it.publish_date)) {
return it.publish_date.slice(0, 10);
}
@@ -43,6 +49,10 @@ export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
const d = (it.created_at || '').slice(0, 10);
return d || null;
}
// Approved + scheduled pieces also show on the calendar day (live plan).
if (st === 'aprobado' && it.scheduled_at) {
return it.scheduled_at.slice(0, 10);
}
return null;
}
@@ -121,6 +131,11 @@ export function mergePipelineIntoPlans(
for (const it of items) {
const day = pipelinePublishDay(it);
if (!day) continue;
// Show published + approved-with-schedule on the live calendar.
const st = (it.status || '').toLowerCase();
if (st !== 'publicado' && !(st === 'aprobado' && it.scheduled_at)) {
continue;
}
const ig = instagramUrlFromNotes(it.notes);
const igKey = normPermalink(ig);

View File

@@ -0,0 +1,56 @@
/** Agency scheduling helpers for creative_pipeline. */
export const PUBLISH_CHANNELS = ['instagram', 'facebook'] as const;
export type PublishChannel = (typeof PUBLISH_CHANNELS)[number];
export const PUBLISH_STATUSES = [
'idle',
'queued',
'publishing',
'published',
'failed',
] as const;
export type PublishStatus = (typeof PUBLISH_STATUSES)[number];
export function normalizeChannels(raw: unknown): PublishChannel[] {
const list = Array.isArray(raw)
? raw.map((x) => String(x).toLowerCase().trim())
: typeof raw === 'string'
? raw.split(',').map((x) => x.toLowerCase().trim())
: [];
const out = PUBLISH_CHANNELS.filter((c) => list.includes(c));
return out.length ? [...out] : ['instagram', 'facebook'];
}
/** Parse datetime-local or ISO into timestamptz ISO, or null to clear. */
export function parseScheduledAt(raw: unknown): string | null | undefined {
if (raw === null) return null;
if (raw === undefined) return undefined;
if (typeof raw !== 'string') return undefined;
const t = raw.trim();
if (!t) return null;
const d = new Date(t);
if (Number.isNaN(d.getTime())) return undefined;
return d.toISOString();
}
export function toDatetimeLocalValue(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function scheduleLabel(iso?: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString('es-ES', {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}