/** * Publish / delete creative_pipeline items on Instagram + Facebook Page * via Meta Graph Content Publishing API. */ import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets'; import { normalizeChannels, type PublishChannel, } from '@/lib/pipeline-schedule'; import { sanitizePublishCaption } from '@/lib/publish-caption'; const META = 'https://graph.facebook.com/v21.0'; export type PipelinePublishRow = { id: string; tenant_id: string; title: string; format: string; caption?: string | null; cover_path?: string | null; cover_mime?: string | null; slide_paths?: unknown; channels?: unknown; meta_ig_media_id?: string | null; meta_fb_post_id?: string | null; publish_attempts?: number | null; }; export type MetaTargets = { pageId: string; igUserId: string | null; pageToken: string; }; export type PublishResult = { ok: boolean; igMediaId?: string | null; fbPostId?: string | null; igPermalink?: string | null; fbPermalink?: string | null; error?: string; channels: PublishChannel[]; /** Caption actually sent to Meta (production noise stripped). */ sanitizedCaption?: string | null; captionStripped?: string[]; }; type GraphJson = Record; function formatGraphError(raw: string): string { try { const j = JSON.parse(raw) as { error?: { message?: string; code?: number; error_subcode?: number; error_user_title?: string; error_user_msg?: string; }; }; const e = j.error; if (!e) return raw.slice(0, 280); if (e.code === 190) { return ( 'Token Meta inválido. Revisa el system user 4V2030 con pages_* + ' + 'instagram_content_publish y asígnalo a la Página del cliente.' ); } return ( e.error_user_title || e.error_user_msg || e.message || raw ).slice(0, 400); } catch { return raw.slice(0, 280); } } async function graphGet( path: string, token: string, query: Record = {} ): Promise<{ ok: boolean; json: GraphJson; raw: string }> { const q = new URLSearchParams({ ...query, access_token: token }); const r = await fetch(`${META}/${path}?${q}`, { cache: 'no-store' }); const raw = await r.text(); let json: GraphJson = {}; try { json = JSON.parse(raw) as GraphJson; } catch { /* keep empty */ } return { ok: r.ok, json, raw }; } async function graphPost( path: string, token: string, body: Record ): Promise<{ ok: boolean; json: GraphJson; raw: string }> { const form = new URLSearchParams({ ...body, access_token: token }); const r = await fetch(`${META}/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), cache: 'no-store', }); const raw = await r.text(); let json: GraphJson = {}; try { json = JSON.parse(raw) as GraphJson; } catch { /* keep empty */ } return { ok: r.ok, json, raw }; } async function graphDelete( path: string, token: string ): Promise<{ ok: boolean; raw: string }> { const r = await fetch( `${META}/${path}?access_token=${encodeURIComponent(token)}`, { method: 'DELETE', cache: 'no-store' } ); return { ok: r.ok, raw: await r.text() }; } async function resolvePageAccessToken( userToken: string, pageId: string ): Promise { const r = await graphGet(encodeURIComponent(pageId), userToken, { fields: 'access_token,name', }); if (r.ok && typeof r.json.access_token === 'string') { return r.json.access_token; } const accounts = await graphGet('me/accounts', userToken, { fields: 'id,access_token', limit: '100', }); if (accounts.ok) { const data = (accounts.json.data as { id?: string; access_token?: string }[]) || []; const hit = data.find((p) => p.id === pageId && p.access_token); if (hit?.access_token) return hit.access_token; } return userToken; } /** Public HTTPS URL Meta can fetch (signed storage, 2h). */ export async function signedAssetUrl( path: string, ttlSec = 7200 ): Promise { if (!path?.trim()) return null; const svc = serviceSupabase(); if (!svc) return null; const signed = await svc.storage .from(ASSETS_BUCKET) .createSignedUrl(path.trim(), ttlSec); if (signed.error || !signed.data?.signedUrl) return null; return signed.data.signedUrl; } function slidePathsOf(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return raw .map((x) => (typeof x === 'string' ? x.trim() : '')) .filter(Boolean); } function isVideoMime(mime?: string | null, path?: string | null): boolean { const m = (mime || '').toLowerCase(); if (m.startsWith('video/')) return true; const p = (path || '').toLowerCase(); return /\.(mp4|mov|m4v|webm)$/.test(p); } async function loadIntegrations(tenantId: string): Promise<{ pageId: string | null; igUserId: string | null; }> { const headers = serviceHeaders(); const base = supabaseUrl(); if (!headers || !base) return { pageId: null, igUserId: null }; const r = await fetch( `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( tenantId )}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id`, { headers, cache: 'no-store' } ); if (!r.ok) return { pageId: null, igUserId: null }; const rows = (await r.json()) as { provider: string; external_account_id?: string | null; }[]; let pageId: string | null = null; let igUserId: string | null = null; for (const row of rows) { const id = row.external_account_id?.trim() || ''; if (!id || id.startsWith('http')) continue; if (row.provider === 'facebook') pageId = id; if (row.provider === 'instagram') igUserId = id; } return { pageId, igUserId }; } /** Block publish if FB Page / IG user id is also stored on another tenant. */ async function assertMetaAccountsExclusive( tenantId: string, pageId: string | null, igUserId: string | null ): Promise<{ ok: boolean; error?: string }> { const ids = [pageId, igUserId].filter( (x): x is string => Boolean(x && x.trim()) ); if (!ids.length) return { ok: true }; const headers = serviceHeaders(); const base = supabaseUrl(); if (!headers || !base) return { ok: true }; const or = ids .map((id) => `external_account_id.eq.${encodeURIComponent(id)}`) .join(','); const r = await fetch( `${base}/rest/v1/integrations?or=(${or})&provider=in.(facebook,instagram)&status=eq.connected&select=tenant_id,provider,external_account_id`, { headers, cache: 'no-store' } ); if (!r.ok) return { ok: true }; const rows = (await r.json()) as { tenant_id: string; provider: string; external_account_id?: string | null; }[]; const foreign = rows.filter((row) => row.tenant_id !== tenantId); if (!foreign.length) return { ok: true }; const detail = foreign .map( (f) => `${f.provider}:${f.external_account_id || '?'}→tenant ${f.tenant_id.slice(0, 8)}` ) .join(', '); return { ok: false, error: `Cuenta Meta también vinculada a otro cliente (${detail}). Corrige Integraciones antes de publicar.`, }; } /** Find Facebook Page that owns an IG business account. */ async function findPageForIg( token: string, igUserId: string ): Promise<{ pageId: string; pageToken: string } | null> { const accounts = await graphGet('me/accounts', token, { fields: 'id,access_token,instagram_business_account{id}', limit: '100', }); if (!accounts.ok) return null; const data = (accounts.json.data as { id?: string; access_token?: string; instagram_business_account?: { id?: string }; }[]) || []; for (const p of data) { if (p.instagram_business_account?.id === igUserId && p.id && p.access_token) { return { pageId: p.id, pageToken: p.access_token }; } } return null; } export async function resolveTenantMetaTargets( tenantId: string ): Promise<{ targets: MetaTargets | null; error?: string }> { const tokens = await resolveMetaAccessTokens(); if (!tokens.length) { return { targets: null, error: 'Sin token Meta. Configura META_ACCESS_TOKEN o pégalo en Agencia (system user 4V2030).', }; } const { pageId: integPage, igUserId } = await loadIntegrations(tenantId); if (!integPage && !igUserId) { return { targets: null, error: 'Cliente sin Facebook Page / Instagram conectados en Integraciones.', }; } // Refuse if the same Page/IG id is linked to another tenant (cross-client leak). const exclusive = await assertMetaAccountsExclusive( tenantId, integPage, igUserId ); if (!exclusive.ok) { return { targets: null, error: exclusive.error }; } let pageId = integPage; let pageToken: string | null = null; let working: string | null = null; for (const t of tokens) { if (pageId) { const probe = await graphGet(encodeURIComponent(pageId), t, { fields: 'id,name', }); if (probe.ok) { working = t; pageToken = await resolvePageAccessToken(t, pageId); break; } } if (igUserId) { const found = await findPageForIg(t, igUserId); if (found) { working = t; pageId = found.pageId; pageToken = found.pageToken; break; } } } if (!working || !pageId || !pageToken) { return { targets: null, error: 'No se pudo obtener token de Página. Revisa que el system user tenga la Página asignada.', }; } let ig = igUserId; if (!ig) { const page = await graphGet(encodeURIComponent(pageId), pageToken, { fields: 'instagram_business_account{id}', }); const iba = page.json.instagram_business_account as { id?: string } | undefined; ig = iba?.id || null; } return { targets: { pageId, igUserId: ig, pageToken, }, }; } async function waitIgContainer( creationId: string, token: string, maxAttempts = 20 ): Promise<{ ok: boolean; error?: string }> { for (let i = 0; i < maxAttempts; i++) { const st = await graphGet(encodeURIComponent(creationId), token, { fields: 'status_code,status', }); if (!st.ok) { return { ok: false, error: formatGraphError(st.raw) }; } const code = String(st.json.status_code || '').toUpperCase(); if (code === 'FINISHED') return { ok: true }; if (code === 'ERROR' || code === 'EXPIRED') { return { ok: false, error: String(st.json.status || `Contenedor IG ${code}`), }; } await new Promise((r) => setTimeout(r, 1500)); } return { ok: false, error: 'Timeout esperando contenedor IG' }; } function publicCaption(item: PipelinePublishRow, maxLen = 2200): string { const raw = item.caption || item.title || ''; return sanitizePublishCaption(raw, { maxLen }).caption || (item.title || '').slice(0, maxLen); } async function publishIgMedia( igUserId: string, pageToken: string, item: PipelinePublishRow ): Promise<{ id: string | null; permalink: string | null; error?: string }> { const caption = publicCaption(item, 2200); const format = (item.format || 'post').toLowerCase(); const slides = slidePathsOf(item.slide_paths); const cover = item.cover_path || slides[0] || null; if (!cover) { return { id: null, permalink: null, error: 'Falta imagen/vídeo (portada)' }; } const video = isVideoMime(item.cover_mime, cover); let creationId: string | null = null; if (format === 'carrusel') { const paths = [cover, ...slides.filter((p) => p !== cover)].slice(0, 10); if (paths.length < 2) { return { id: null, permalink: null, error: 'Carrusel necesita al menos 2 imágenes', }; } const childIds: string[] = []; for (const path of paths) { const url = await signedAssetUrl(path); if (!url) { return { id: null, permalink: null, error: `No URL firmada: ${path}` }; } const child = await graphPost(`${encodeURIComponent(igUserId)}/media`, pageToken, { image_url: url, is_carousel_item: 'true', }); if (!child.ok || typeof child.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(child.raw) }; } childIds.push(child.json.id); } const parent = await graphPost(`${encodeURIComponent(igUserId)}/media`, pageToken, { media_type: 'CAROUSEL', children: childIds.join(','), caption, }); if (!parent.ok || typeof parent.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(parent.raw) }; } creationId = parent.json.id; } else if (format === 'reel' || (format === 'post' && video)) { const url = await signedAssetUrl(cover); if (!url) { return { id: null, permalink: null, error: 'No URL firmada para vídeo' }; } const body: Record = { media_type: 'REELS', video_url: url, caption, share_to_feed: 'true', }; const created = await graphPost( `${encodeURIComponent(igUserId)}/media`, pageToken, body ); if (!created.ok || typeof created.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(created.raw) }; } creationId = created.json.id; } else if (format === 'story') { const url = await signedAssetUrl(cover); if (!url) { return { id: null, permalink: null, error: 'No URL firmada para story' }; } const body: Record = video ? { media_type: 'STORIES', video_url: url } : { media_type: 'STORIES', image_url: url }; const created = await graphPost( `${encodeURIComponent(igUserId)}/media`, pageToken, body ); if (!created.ok || typeof created.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(created.raw) }; } creationId = created.json.id; } else { const url = await signedAssetUrl(cover); if (!url) { return { id: null, permalink: null, error: 'No URL firmada para imagen' }; } const created = await graphPost( `${encodeURIComponent(igUserId)}/media`, pageToken, { image_url: url, caption } ); if (!created.ok || typeof created.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(created.raw) }; } creationId = created.json.id; } const ready = await waitIgContainer(creationId, pageToken); if (!ready.ok) { return { id: null, permalink: null, error: ready.error }; } const pub = await graphPost( `${encodeURIComponent(igUserId)}/media_publish`, pageToken, { creation_id: creationId } ); if (!pub.ok || typeof pub.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(pub.raw) }; } const mediaId = pub.json.id; const per = await graphGet(encodeURIComponent(mediaId), pageToken, { fields: 'permalink', }); const permalink = per.ok && typeof per.json.permalink === 'string' ? per.json.permalink : null; return { id: mediaId, permalink }; } async function publishFbFeedWithPhotos( pageId: string, pageToken: string, message: string, photoIds: string[] ): Promise<{ id: string | null; permalink: string | null; error?: string }> { if (!photoIds.length) { return { id: null, permalink: null, error: 'Sin fotos para el feed FB' }; } const form = new URLSearchParams({ message, access_token: pageToken, }); photoIds.forEach((id, i) => { form.append(`attached_media[${i}]`, JSON.stringify({ media_fbid: id })); }); const r = await fetch(`${META}/${encodeURIComponent(pageId)}/feed`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), cache: 'no-store', }); const raw = await r.text(); let json: GraphJson = {}; try { json = JSON.parse(raw) as GraphJson; } catch { /* */ } if (!r.ok || typeof json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(raw) }; } return { id: json.id, permalink: null }; } async function publishFbPost( pageId: string, pageToken: string, item: PipelinePublishRow ): Promise<{ id: string | null; permalink: string | null; error?: string }> { // Prefer /feed + unpublished photos so the post lands on the Page timeline // (direct /photos published=true often only shows in Fotos, not on top). const message = publicCaption(item, 6000); const format = (item.format || 'post').toLowerCase(); const slides = slidePathsOf(item.slide_paths); const cover = item.cover_path || slides[0] || null; const video = isVideoMime(item.cover_mime, cover); if (!cover) { const feed = await graphPost(`${encodeURIComponent(pageId)}/feed`, pageToken, { message, }); if (!feed.ok || typeof feed.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(feed.raw) }; } return { id: feed.json.id, permalink: null }; } if (format === 'reel' || video) { const url = await signedAssetUrl(cover); if (!url) { return { id: null, permalink: null, error: 'No URL firmada FB vídeo' }; } const vid = await graphPost(`${encodeURIComponent(pageId)}/videos`, pageToken, { file_url: url, description: message, published: 'true', }); if (!vid.ok || typeof vid.json.id !== 'string') { return { id: null, permalink: null, error: formatGraphError(vid.raw) }; } return { id: vid.json.id, permalink: null }; } const paths = format === 'carrusel' ? [cover, ...slides.filter((p) => p !== cover)].slice(0, 10) : [cover]; const mediaIds: string[] = []; for (const path of paths) { const url = await signedAssetUrl(path); if (!url) continue; const ph = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, { url, published: 'false', }); if (ph.ok && typeof ph.json.id === 'string') { mediaIds.push(ph.json.id); } } if (format === 'carrusel' && mediaIds.length < 2) { return { id: null, permalink: null, error: 'Carrusel FB necesita al menos 2 imágenes', }; } if (!mediaIds.length) { return { id: null, permalink: null, error: 'No URL firmada FB imagen' }; } return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds); } export async function publishPipelineItem( item: PipelinePublishRow, opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean } ): Promise { const channels = normalizeChannels(item.channels); const scrub = sanitizePublishCaption(item.caption || item.title || '', { maxLen: 6000, }); const cleanItem: PipelinePublishRow = { ...item, caption: scrub.caption || item.title || '', }; const resolved = await resolveTenantMetaTargets(item.tenant_id); if (!resolved.targets) { return { ok: false, error: resolved.error, channels, sanitizedCaption: scrub.caption, captionStripped: scrub.stripped, }; } const { pageId, igUserId, pageToken } = resolved.targets; let igMediaId: string | null = opts?.forceIg ? null : item.meta_ig_media_id || null; let fbPostId: string | null = opts?.forceFb ? null : item.meta_fb_post_id || null; let igPermalink: string | null = null; let fbPermalink: string | null = null; const errors: string[] = []; const wantIg = channels.includes('instagram') && !opts?.skipIg; const wantFb = channels.includes('facebook') && !opts?.skipFb; if (wantIg) { if (!igUserId) { errors.push('Sin Instagram Business vinculado a la Página'); } else if (!igMediaId) { const ig = await publishIgMedia(igUserId, pageToken, cleanItem); if (ig.error || !ig.id) { errors.push(`IG: ${ig.error || 'fallo'}`); } else { igMediaId = ig.id; igPermalink = ig.permalink; } } } if (wantFb) { if (!fbPostId) { const fb = await publishFbPost(pageId, pageToken, cleanItem); if (fb.error || !fb.id) { errors.push(`FB: ${fb.error || 'fallo'}`); } else { fbPostId = fb.id; fbPermalink = fb.permalink; } } } const igOk = !wantIg || Boolean(igMediaId); const fbOk = !wantFb || Boolean(fbPostId); const ok = igOk && fbOk && errors.length === 0; return { ok, igMediaId, fbPostId, igPermalink, fbPermalink, error: errors.length ? errors.join(' · ') : undefined, channels, sanitizedCaption: scrub.caption, captionStripped: scrub.stripped, }; } export type DeleteMetaResult = { ok: boolean; /** At least one network deleted or nothing to delete */ partialOk: boolean; error?: string; igDeleted: boolean; fbDeleted: boolean; /** Clear these ids in DB after the call */ clearIg: boolean; clearFb: boolean; }; async function deleteFacebookObject( fbId: string, pageToken: string, pageId: string ): Promise<{ ok: boolean; error?: string }> { const candidates = [fbId]; // pageId_postId → also try bare post id if (fbId.includes('_')) { const bare = fbId.split('_').slice(1).join('_'); if (bare) candidates.push(bare); } // Sometimes we stored photo id; try deleting as page post via feed candidates.push(`${pageId}_${fbId}`); const errors: string[] = []; for (const cand of [...new Set(candidates)]) { const d = await graphDelete(encodeURIComponent(cand), pageToken); if (d.ok) return { ok: true }; // Already gone counts as success const msg = formatGraphError(d.raw).toLowerCase(); if ( msg.includes('does not exist') || msg.includes('unsupported get request') || msg.includes('no existe') || msg.includes('(#100)') ) { return { ok: true }; } errors.push(formatGraphError(d.raw)); } return { ok: false, error: errors[0] || 'No se pudo borrar en Facebook' }; } export async function deleteMetaPosts(item: { tenant_id: string; meta_ig_media_id?: string | null; meta_fb_post_id?: string | null; }): Promise { const igId = item.meta_ig_media_id?.trim(); const fbId = item.meta_fb_post_id?.trim(); if (!igId && !fbId) { return { ok: true, partialOk: true, igDeleted: false, fbDeleted: false, clearIg: true, clearFb: true, }; } const resolved = await resolveTenantMetaTargets(item.tenant_id); if (!resolved.targets) { return { ok: false, partialOk: false, error: resolved.error, igDeleted: false, fbDeleted: false, clearIg: false, clearFb: false, }; } const { pageToken, pageId } = resolved.targets; const errors: string[] = []; let igDeleted = false; let fbDeleted = false; if (igId) { const d = await graphDelete(encodeURIComponent(igId), pageToken); if (d.ok) { igDeleted = true; } else { const msg = formatGraphError(d.raw).toLowerCase(); if ( msg.includes('does not exist') || msg.includes('unsupported') || msg.includes('no existe') ) { igDeleted = true; // already gone } else { errors.push(`IG: ${formatGraphError(d.raw)}`); } } } else { igDeleted = true; } if (fbId) { const fb = await deleteFacebookObject(fbId, pageToken, pageId); if (fb.ok) { fbDeleted = true; } else { errors.push(`FB: ${fb.error || 'fallo'}`); } } else { fbDeleted = true; } const ok = igDeleted && fbDeleted; return { ok, partialOk: igDeleted || fbDeleted, error: errors.length ? errors.join(' · ') : undefined, igDeleted, fbDeleted, clearIg: igDeleted, clearFb: fbDeleted, }; } /** * Update live posts with current caption/media: * - Facebook: edit message in place when possible * - Instagram: delete + republish (API no permite editar caption) */ export async function updateLivePipelineItem( item: PipelinePublishRow ): Promise<{ ok: boolean; error?: string; igMediaId?: string | null; fbPostId?: string | null; igPermalink?: string | null; detail?: string; }> { const channels = normalizeChannels(item.channels); const resolved = await resolveTenantMetaTargets(item.tenant_id); if (!resolved.targets) { return { ok: false, error: resolved.error }; } const { pageId, igUserId, pageToken } = resolved.targets; const scrub = sanitizePublishCaption(item.caption || item.title || '', { maxLen: 2200, }); const caption = scrub.caption || (item.title || '').slice(0, 2200); const cleanItem: PipelinePublishRow = { ...item, caption }; const notes: string[] = []; const errors: string[] = []; if (scrub.stripped.length) { notes.push(`Copy: quitadas ${scrub.stripped.length} notas internas`); } let igMediaId: string | null = item.meta_ig_media_id || null; let fbPostId: string | null = item.meta_fb_post_id || null; let igPermalink: string | null = null; // Facebook: try in-place caption edit if (channels.includes('facebook') && fbPostId) { const edit = await graphPost(encodeURIComponent(fbPostId), pageToken, { message: caption, }); if (edit.ok) { notes.push('FB: texto actualizado'); } else { // Fallback: delete + republish FB const del = await deleteFacebookObject(fbPostId, pageToken, pageId); if (!del.ok) { errors.push(`FB edit: ${formatGraphError(edit.raw)}`); } else { const pub = await publishFbPost(pageId, pageToken, { ...cleanItem, meta_fb_post_id: null, }); if (!pub.id) { errors.push(`FB republicar: ${pub.error || 'fallo'}`); fbPostId = null; } else { fbPostId = pub.id; notes.push('FB: republicado (no se pudo editar in-place)'); } } } } else if (channels.includes('facebook') && !fbPostId) { const pub = await publishFbPost(pageId, pageToken, cleanItem); if (!pub.id) errors.push(`FB: ${pub.error || 'fallo'}`); else { fbPostId = pub.id; notes.push('FB: publicado'); } } // Instagram: must delete + republish — never publish a second copy if delete fails. if (channels.includes('instagram')) { if (!igUserId) { errors.push('Sin Instagram Business vinculado'); } else { let canRepublish = true; if (igMediaId) { const d = await graphDelete(encodeURIComponent(igMediaId), pageToken); const gone = d.ok || formatGraphError(d.raw).toLowerCase().includes('does not exist') || formatGraphError(d.raw).toLowerCase().includes('unsupported') || formatGraphError(d.raw).toLowerCase().includes('no existe'); if (!gone) { errors.push( `IG borrar: ${formatGraphError(d.raw)} (no se republica para evitar duplicado)` ); canRepublish = false; } else { igMediaId = null; } } if (canRepublish) { const pub = await publishIgMedia(igUserId, pageToken, { ...cleanItem, meta_ig_media_id: null, }); if (!pub.id) { errors.push(`IG republicar: ${pub.error || 'fallo'}`); } else { igMediaId = pub.id; igPermalink = pub.permalink; notes.push('IG: republicado con nuevo copy'); } } } } return { ok: errors.length === 0, error: errors.length ? errors.join(' · ') : undefined, igMediaId, fbPostId, igPermalink, detail: notes.join(' · ') || undefined, }; } export function mergePermalinkNotes( notes: string | null | undefined, igPermalink?: string | null, fbPermalink?: string | null ): string | null { const parts: string[] = []; const base = (notes || '').trim(); if (base && !/instagram\.com/i.test(base)) parts.push(base); else if (base) parts.push(base.replace(/https?:\/\/(?:www\.)?instagram\.com\S+/gi, '').trim()); if (igPermalink) parts.push(igPermalink); if (fbPermalink) parts.push(`Facebook: ${fbPermalink}`); const out = parts.filter(Boolean).join('\n').trim(); return out || null; }