fix: vídeos en Social y notas Superhuman por día desde Drive
Los docs de seguimiento son Google Docs embebidos: se exportan, se parten por fecha y se acumulan. Social renderiza VIDEO/REELS y trae vídeos de página. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -99,7 +99,7 @@ export async function POST(req: NextRequest) {
|
|||||||
? String(body.tenantId)
|
? String(body.tenantId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const exportBodies = body.exportBodies !== false;
|
const exportBodies = body.exportBodies !== false;
|
||||||
const maxEntries = Math.min(Number(body.maxEntries) || 24, 40);
|
const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const docs = await listDocs();
|
const docs = await listDocs();
|
||||||
@@ -175,6 +175,7 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const rows = await buildSeguimientoRowsForDoc({
|
const rows = await buildSeguimientoRowsForDoc({
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
|
tenantName: tenant.name,
|
||||||
docId,
|
docId,
|
||||||
maxEntries,
|
maxEntries,
|
||||||
exportBodies,
|
exportBodies,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function GET() {
|
|||||||
if (agency && ctx.viaView) {
|
if (agency && ctx.viaView) {
|
||||||
const tid = encodeURIComponent(tenantId);
|
const tid = encodeURIComponent(tenantId);
|
||||||
const e = await restGet<SeguimientoEntry[]>(
|
const e = await restGet<SeguimientoEntry[]>(
|
||||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=200`
|
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=300`
|
||||||
);
|
);
|
||||||
entries = e.data || [];
|
entries = e.data || [];
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +65,7 @@ export async function GET() {
|
|||||||
.from('seguimiento_entries')
|
.from('seguimiento_entries')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('occurred_at', { ascending: false, nullsFirst: false })
|
.order('occurred_at', { ascending: false, nullsFirst: false })
|
||||||
.limit(200);
|
.limit(300);
|
||||||
entries = (e as SeguimientoEntry[]) || [];
|
entries = (e as SeguimientoEntry[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type SocialPost = {
|
|||||||
platform: 'facebook' | 'instagram';
|
platform: 'facebook' | 'instagram';
|
||||||
message?: string;
|
message?: string;
|
||||||
mediaUrl?: string | null;
|
mediaUrl?: string | null;
|
||||||
|
/** Poster / still for VIDEO / REELS when mediaUrl is a video. */
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
permalink?: string | null;
|
permalink?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
likes: number;
|
likes: number;
|
||||||
@@ -33,6 +35,17 @@ type SocialPost = {
|
|||||||
mediaType?: string | null;
|
mediaType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isVideoType(t?: string | null): boolean {
|
||||||
|
const s = (t || '').toUpperCase();
|
||||||
|
return (
|
||||||
|
s === 'VIDEO' ||
|
||||||
|
s === 'REELS' ||
|
||||||
|
s === 'REEL' ||
|
||||||
|
s.includes('VIDEO') ||
|
||||||
|
s === 'ANIMATED_IMAGE_VIDEO'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function insightValue(
|
function insightValue(
|
||||||
data: { name?: string; values?: { value?: number }[] }[] | undefined,
|
data: { name?: string; values?: { value?: number }[] }[] | undefined,
|
||||||
name: string
|
name: string
|
||||||
@@ -70,30 +83,75 @@ async function fetchPageBundle(token: string, pageId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postsRes = await fetch(
|
const postsRes = await fetch(
|
||||||
`${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,shares,likes.summary(true),comments.summary(true)&limit=12&access_token=${encodeURIComponent(token)}`,
|
`${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`,
|
||||||
{ cache: 'no-store' }
|
{ cache: 'no-store' }
|
||||||
);
|
);
|
||||||
const posts: SocialPost[] = [];
|
const posts: SocialPost[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
if (postsRes.ok) {
|
if (postsRes.ok) {
|
||||||
const j = await postsRes.json();
|
const j = await postsRes.json();
|
||||||
for (const p of j.data || []) {
|
for (const p of j.data || []) {
|
||||||
|
const att = p.attachments?.data?.[0];
|
||||||
|
const attType = String(att?.media_type || att?.type || '').toLowerCase();
|
||||||
|
const videoSrc =
|
||||||
|
att?.media?.source ||
|
||||||
|
att?.url ||
|
||||||
|
att?.subattachments?.data?.[0]?.media?.source ||
|
||||||
|
null;
|
||||||
|
const isVideo =
|
||||||
|
attType.includes('video') ||
|
||||||
|
String(p.status_type || '').includes('video') ||
|
||||||
|
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
|
||||||
|
const thumb =
|
||||||
|
p.full_picture ||
|
||||||
|
att?.media?.image?.src ||
|
||||||
|
att?.subattachments?.data?.[0]?.media?.image?.src ||
|
||||||
|
null;
|
||||||
posts.push({
|
posts.push({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
platform: 'facebook',
|
platform: 'facebook',
|
||||||
message: p.message || '',
|
message: p.message || '',
|
||||||
mediaUrl: p.full_picture || null,
|
mediaUrl: isVideo ? videoSrc || thumb : thumb,
|
||||||
|
thumbnailUrl: thumb,
|
||||||
permalink: p.permalink_url || null,
|
permalink: p.permalink_url || null,
|
||||||
createdAt: p.created_time || null,
|
createdAt: p.created_time || null,
|
||||||
likes: Number(p.likes?.summary?.total_count || 0),
|
likes: Number(p.likes?.summary?.total_count || 0),
|
||||||
comments: Number(p.comments?.summary?.total_count || 0),
|
comments: Number(p.comments?.summary?.total_count || 0),
|
||||||
shares: Number(p.shares?.count || 0),
|
shares: Number(p.shares?.count || 0),
|
||||||
mediaType: p.full_picture ? 'image' : 'status',
|
mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status',
|
||||||
});
|
});
|
||||||
|
seen.add(p.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
profile.postsError = (await postsRes.text()).slice(0, 160);
|
profile.postsError = (await postsRes.text()).slice(0, 160);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native page videos / reels often missing from /posts
|
||||||
|
const videosRes = await fetch(
|
||||||
|
`${META}/${encodeURIComponent(pageId)}/videos?fields=id,description,created_time,source,permalink_url,picture,length,title&limit=10&access_token=${encodeURIComponent(token)}`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (videosRes.ok) {
|
||||||
|
const j = await videosRes.json();
|
||||||
|
for (const v of j.data || []) {
|
||||||
|
if (!v?.id || seen.has(v.id)) continue;
|
||||||
|
posts.push({
|
||||||
|
id: v.id,
|
||||||
|
platform: 'facebook',
|
||||||
|
message: v.description || v.title || '',
|
||||||
|
mediaUrl: v.source || v.picture || null,
|
||||||
|
thumbnailUrl: v.picture || null,
|
||||||
|
permalink: v.permalink_url || null,
|
||||||
|
createdAt: v.created_time || null,
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
shares: 0,
|
||||||
|
mediaType: 'VIDEO',
|
||||||
|
});
|
||||||
|
seen.add(v.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ...profile, pageInsights, posts };
|
return { ...profile, pageInsights, posts };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,18 +195,29 @@ async function fetchIgBundle(token: string, igId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaRes = await fetch(
|
const mediaRes = await fetch(
|
||||||
`${META}/${encodeURIComponent(igId)}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count&limit=12&access_token=${encodeURIComponent(token)}`,
|
`${META}/${encodeURIComponent(igId)}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count&limit=16&access_token=${encodeURIComponent(token)}`,
|
||||||
{ cache: 'no-store' }
|
{ cache: 'no-store' }
|
||||||
);
|
);
|
||||||
const posts: SocialPost[] = [];
|
const posts: SocialPost[] = [];
|
||||||
if (mediaRes.ok) {
|
if (mediaRes.ok) {
|
||||||
const j = await mediaRes.json();
|
const j = await mediaRes.json();
|
||||||
for (const m of j.data || []) {
|
for (const m of j.data || []) {
|
||||||
|
const mediaType = m.media_type || null;
|
||||||
|
const video = isVideoType(mediaType);
|
||||||
|
// VIDEO/REELS: keep media_url for <video>; thumbnail for poster
|
||||||
|
const mediaUrl = video
|
||||||
|
? m.media_url || m.thumbnail_url || null
|
||||||
|
: m.media_url || m.thumbnail_url || null;
|
||||||
|
const thumbnailUrl = m.thumbnail_url || (video ? null : m.media_url) || null;
|
||||||
let reach: number | null = null;
|
let reach: number | null = null;
|
||||||
let impressions: number | null = null;
|
let impressions: number | null = null;
|
||||||
|
let shares = 0;
|
||||||
try {
|
try {
|
||||||
|
const metrics = video
|
||||||
|
? 'reach,saved,shares,plays'
|
||||||
|
: 'reach,impressions,saved,shares';
|
||||||
const mi = await fetch(
|
const mi = await fetch(
|
||||||
`${META}/${encodeURIComponent(m.id)}/insights?metric=reach,impressions,saved,shares&access_token=${encodeURIComponent(token)}`,
|
`${META}/${encodeURIComponent(m.id)}/insights?metric=${metrics}&access_token=${encodeURIComponent(token)}`,
|
||||||
{ cache: 'no-store' }
|
{ cache: 'no-store' }
|
||||||
);
|
);
|
||||||
if (mi.ok) {
|
if (mi.ok) {
|
||||||
@@ -158,23 +227,10 @@ async function fetchIgBundle(token: string, igId: string) {
|
|||||||
values?: { value?: number }[];
|
values?: { value?: number }[];
|
||||||
}[];
|
}[];
|
||||||
reach = insightValue(data, 'reach');
|
reach = insightValue(data, 'reach');
|
||||||
impressions = insightValue(data, 'impressions');
|
impressions =
|
||||||
const shares = insightValue(data, 'shares');
|
insightValue(data, 'impressions') ??
|
||||||
posts.push({
|
insightValue(data, 'plays');
|
||||||
id: m.id,
|
shares = insightValue(data, 'shares') ?? 0;
|
||||||
platform: 'instagram',
|
|
||||||
message: m.caption || '',
|
|
||||||
mediaUrl: m.media_url || m.thumbnail_url || null,
|
|
||||||
permalink: m.permalink || null,
|
|
||||||
createdAt: m.timestamp || null,
|
|
||||||
likes: Number(m.like_count || 0),
|
|
||||||
comments: Number(m.comments_count || 0),
|
|
||||||
shares: shares ?? 0,
|
|
||||||
reach,
|
|
||||||
impressions,
|
|
||||||
mediaType: m.media_type || null,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through without per-media insights */
|
/* fall through without per-media insights */
|
||||||
@@ -183,13 +239,16 @@ async function fetchIgBundle(token: string, igId: string) {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
platform: 'instagram',
|
platform: 'instagram',
|
||||||
message: m.caption || '',
|
message: m.caption || '',
|
||||||
mediaUrl: m.media_url || m.thumbnail_url || null,
|
mediaUrl,
|
||||||
|
thumbnailUrl,
|
||||||
permalink: m.permalink || null,
|
permalink: m.permalink || null,
|
||||||
createdAt: m.timestamp || null,
|
createdAt: m.timestamp || null,
|
||||||
likes: Number(m.like_count || 0),
|
likes: Number(m.like_count || 0),
|
||||||
comments: Number(m.comments_count || 0),
|
comments: Number(m.comments_count || 0),
|
||||||
shares: 0,
|
shares,
|
||||||
mediaType: m.media_type || null,
|
reach,
|
||||||
|
impressions,
|
||||||
|
mediaType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
import { useDateRange } from '@/components/DateRangeContext';
|
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,14 +48,7 @@ function typeTone(t: string) {
|
|||||||
return 'bg-violet-50 text-violet-800 border-violet-200';
|
return 'bg-violet-50 text-violet-800 border-violet-200';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inRange(isoDate: string | undefined | null, since: string, until: string) {
|
|
||||||
if (!isoDate) return true; // keep undated notes visible
|
|
||||||
const d = isoDate.slice(0, 10);
|
|
||||||
return d >= since && d <= until;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SeguimientoBoard() {
|
export default function SeguimientoBoard() {
|
||||||
const { since, until, label: rangeLabel } = useDateRange();
|
|
||||||
const [data, setData] = useState<Payload | null>(null);
|
const [data, setData] = useState<Payload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -90,13 +82,14 @@ export default function SeguimientoBoard() {
|
|||||||
const filteredEntries = useMemo(() => {
|
const filteredEntries = useMemo(() => {
|
||||||
const entries = data?.entries || [];
|
const entries = data?.entries || [];
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
|
// Histórico completo (más reciente → más antiguo). El periodo del header
|
||||||
|
// no oculta notas: el doc Superhuman/Drive acumula todos los días.
|
||||||
return entries.filter((e) => {
|
return entries.filter((e) => {
|
||||||
if (!inRange(e.occurred_at, since, until)) return false;
|
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase();
|
const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase();
|
||||||
return blob.includes(q);
|
return blob.includes(q);
|
||||||
});
|
});
|
||||||
}, [data, query, since, until]);
|
}, [data, query]);
|
||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
if (!noteBody.trim()) return;
|
if (!noteBody.trim()) return;
|
||||||
@@ -136,7 +129,7 @@ export default function SeguimientoBoard() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
exportBodies: true,
|
exportBodies: true,
|
||||||
maxEntries: 80,
|
maxEntries: 120,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const json = await res.json().catch(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
@@ -195,7 +188,7 @@ export default function SeguimientoBoard() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Operación"
|
eyebrow="Operación"
|
||||||
title="Seguimiento"
|
title="Seguimiento"
|
||||||
description={`${data.label} · notas e histórico del equipo (periodo ${rangeLabel}). Ads y KPIs están en Métricas.`}
|
description={`${data.label} · histórico de notas (más reciente primero). El periodo del header no filtra este timeline.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">
|
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type SocialPost = {
|
|||||||
platform: 'facebook' | 'instagram';
|
platform: 'facebook' | 'instagram';
|
||||||
message?: string;
|
message?: string;
|
||||||
mediaUrl?: string | null;
|
mediaUrl?: string | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
permalink?: string | null;
|
permalink?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
likes: number;
|
likes: number;
|
||||||
@@ -73,6 +74,13 @@ type SocialPost = {
|
|||||||
mediaType?: string | null;
|
mediaType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isVideoPost(p: SocialPost): boolean {
|
||||||
|
const t = (p.mediaType || '').toUpperCase();
|
||||||
|
if (t.includes('VIDEO') || t === 'REELS' || t === 'REEL') return true;
|
||||||
|
const url = p.mediaUrl || '';
|
||||||
|
return /\.mp4($|\?)/i.test(url) || /video/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
const CHANNELS: {
|
const CHANNELS: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -307,10 +315,19 @@ export default function SocialDashboard({
|
|||||||
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-square bg-gray-100">
|
<div className="relative aspect-square bg-gray-100">
|
||||||
{p.mediaUrl ? (
|
{p.mediaUrl && isVideoPost(p) ? (
|
||||||
|
<video
|
||||||
|
src={p.mediaUrl}
|
||||||
|
poster={p.thumbnailUrl || undefined}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : p.mediaUrl || p.thumbnailUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={p.mediaUrl}
|
src={p.thumbnailUrl || p.mediaUrl || ''}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/** Superhuman Docs (= Coda) client: match docs↔tenants, sync seguimiento pages. */
|
/** Superhuman Docs (= Coda) client: match docs↔tenants, sync seguimiento pages.
|
||||||
|
* Las notas semanales viven en Google Docs embebidos («Seguimiento …»);
|
||||||
|
* se exportan vía Drive y se parten en una entrada por día.
|
||||||
|
*/
|
||||||
|
|
||||||
import { gunzipSync } from 'zlib';
|
import { gunzipSync } from 'zlib';
|
||||||
import { matchAccountsToTenants, normalizeName, scoreNameToTenant } from '@/lib/match';
|
import { matchAccountsToTenants, normalizeName, scoreNameToTenant } from '@/lib/match';
|
||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
import { getValidAccessToken } from '@/lib/google-drive';
|
||||||
|
|
||||||
export const SUPERHUMAN_PROVIDER = 'superhuman_docs' as const;
|
export const SUPERHUMAN_PROVIDER = 'superhuman_docs' as const;
|
||||||
export const SUPERHUMAN_API_BASE = 'https://docs.superhuman.com/apis/v1';
|
export const SUPERHUMAN_API_BASE = 'https://docs.superhuman.com/apis/v1';
|
||||||
@@ -20,8 +24,10 @@ export type ShPage = {
|
|||||||
browserLink?: string;
|
browserLink?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
contentType?: string;
|
||||||
parent?: { id: string; name?: string } | null;
|
parent?: { id: string; name?: string } | null;
|
||||||
children?: { id: string; name?: string }[];
|
children?: { id: string; name?: string }[];
|
||||||
|
icon?: { name?: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedMetrics = {
|
export type ParsedMetrics = {
|
||||||
@@ -48,6 +54,14 @@ export type SeguimientoSyncRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DayNoteSection = {
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
dateIso: string;
|
||||||
|
/** Original heading e.g. 18/9/26 */
|
||||||
|
label: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
function apiKey(): string | null {
|
function apiKey(): string | null {
|
||||||
return process.env.SUPERHUMAN_DOCS_API_KEY?.trim() || null;
|
return process.env.SUPERHUMAN_DOCS_API_KEY?.trim() || null;
|
||||||
}
|
}
|
||||||
@@ -153,6 +167,55 @@ export function parseMetricsFromMarkdown(md: string): ParsedMetrics {
|
|||||||
return metrics;
|
return metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split plain-text / markdown seguimiento docs into one section per day heading. */
|
||||||
|
export function splitNotesByDay(text: string): DayNoteSection[] {
|
||||||
|
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||||
|
const dayRe =
|
||||||
|
/^(?:#{1,4}\s*)?(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})\s*$/;
|
||||||
|
const lines = clean.split('\n');
|
||||||
|
const hits: { idx: number; label: string; dateIso: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const m = lines[i].trim().match(dayRe);
|
||||||
|
if (!m) continue;
|
||||||
|
let y = Number(m[3]);
|
||||||
|
const mo = Number(m[2]);
|
||||||
|
const d = Number(m[1]);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) continue;
|
||||||
|
const dateIso = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
hits.push({
|
||||||
|
idx: i,
|
||||||
|
label: `${d}/${mo}/${String(y).slice(2)}`,
|
||||||
|
dateIso,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hits.length) return [];
|
||||||
|
|
||||||
|
const sections: DayNoteSection[] = [];
|
||||||
|
for (let h = 0; h < hits.length; h++) {
|
||||||
|
const start = hits[h].idx + 1;
|
||||||
|
const end = h + 1 < hits.length ? hits[h + 1].idx : lines.length;
|
||||||
|
const body = lines
|
||||||
|
.slice(start, end)
|
||||||
|
.join('\n')
|
||||||
|
.replace(/^\n+/, '')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
if (!body) continue;
|
||||||
|
sections.push({
|
||||||
|
dateIso: hits[h].dateIso,
|
||||||
|
label: hits[h].label,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest → oldest
|
||||||
|
sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso));
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
async function exportPageMarkdown(
|
async function exportPageMarkdown(
|
||||||
docId: string,
|
docId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -205,17 +268,17 @@ function excerpt(md: string, max = 1400): string {
|
|||||||
return clean.slice(0, max).trimEnd() + '…';
|
return clean.slice(0, max).trimEnd() + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyEntry(title: string, underSeguimiento: boolean): SeguimientoSyncRow['entry_type'] {
|
function classifyEntry(
|
||||||
|
title: string,
|
||||||
|
underSeguimiento: boolean
|
||||||
|
): SeguimientoSyncRow['entry_type'] {
|
||||||
if (KPI_RE.test(title)) return 'kpis';
|
if (KPI_RE.test(title)) return 'kpis';
|
||||||
if (/reuni[oó]n|kick\s*off|seguimiento semanal/i.test(title)) return 'meeting';
|
if (/reuni[oó]n|kick\s*off|seguimiento semanal/i.test(title)) return 'meeting';
|
||||||
if (underSeguimiento) return 'topic';
|
if (underSeguimiento) return 'topic';
|
||||||
return 'note';
|
return 'note';
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageAncestors(
|
function pageAncestors(page: ShPage, byId: Map<string, ShPage>): ShPage[] {
|
||||||
page: ShPage,
|
|
||||||
byId: Map<string, ShPage>
|
|
||||||
): ShPage[] {
|
|
||||||
const chain: ShPage[] = [];
|
const chain: ShPage[] = [];
|
||||||
let cur: ShPage | undefined = page;
|
let cur: ShPage | undefined = page;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -248,12 +311,15 @@ export function selectClientFacingPages(pages: ShPage[]): {
|
|||||||
if (ancestors.some((a) => isPasswordSensitiveName(a.name))) continue;
|
if (ancestors.some((a) => isPasswordSensitiveName(a.name))) continue;
|
||||||
if (ancestors.some((a) => AGENCY_ONLY_RE.test(a.name || ''))) continue;
|
if (ancestors.some((a) => AGENCY_ONLY_RE.test(a.name || ''))) continue;
|
||||||
|
|
||||||
const underSeg = ancestors.some((a) => rootIds.has(a.id) && SEGUIMIENTO_RE.test(a.name));
|
const underSeg = ancestors.some(
|
||||||
|
(a) => rootIds.has(a.id) && SEGUIMIENTO_RE.test(a.name)
|
||||||
|
);
|
||||||
const isRoot = rootIds.has(page.id);
|
const isRoot = rootIds.has(page.id);
|
||||||
const underKpi = ancestors.some((a) => rootIds.has(a.id) && KPI_RE.test(a.name));
|
const underKpi = ancestors.some(
|
||||||
|
(a) => rootIds.has(a.id) && KPI_RE.test(a.name)
|
||||||
|
);
|
||||||
|
|
||||||
if (isRoot || underSeg || (underKpi && page.parent)) {
|
if (isRoot || underSeg || (underKpi && page.parent)) {
|
||||||
// Skip deep password children already filtered; skip empty password roots
|
|
||||||
if (isPasswordSensitiveName(page.name)) continue;
|
if (isPasswordSensitiveName(page.name)) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
page,
|
page,
|
||||||
@@ -262,7 +328,6 @@ export function selectClientFacingPages(pages: ShPage[]): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer freshest first; cap later at sync
|
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
const ta = Date.parse(a.page.updatedAt || a.page.createdAt || '') || 0;
|
const ta = Date.parse(a.page.updatedAt || a.page.createdAt || '') || 0;
|
||||||
const tb = Date.parse(b.page.updatedAt || b.page.createdAt || '') || 0;
|
const tb = Date.parse(b.page.updatedAt || b.page.createdAt || '') || 0;
|
||||||
@@ -322,62 +387,236 @@ export function bestDocForTenant(
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DriveDocHit = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
webViewLink?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Find Google Doc «Seguimiento …» matching page/tenant name. */
|
||||||
|
export async function findSeguimientoGoogleDoc(
|
||||||
|
hints: string[]
|
||||||
|
): Promise<DriveDocHit | null> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const cleaned = hints
|
||||||
|
.map((h) => h.replace(/^\s*\d+\.\s*/, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const name of cleaned) {
|
||||||
|
const safe = name.replace(/'/g, "\\'");
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: `name='${safe}' and mimeType='application/vnd.google-apps.document' and trashed=false`,
|
||||||
|
fields: 'files(id,name,webViewLink,modifiedTime)',
|
||||||
|
pageSize: '5',
|
||||||
|
corpora: 'allDrives',
|
||||||
|
includeItemsFromAllDrives: 'true',
|
||||||
|
supportsAllDrives: 'true',
|
||||||
|
});
|
||||||
|
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) continue;
|
||||||
|
const files = ((await r.json()) as { files?: DriveDocHit[] }).files || [];
|
||||||
|
if (files[0]) return files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false",
|
||||||
|
fields: 'files(id,name,webViewLink,modifiedTime)',
|
||||||
|
pageSize: '40',
|
||||||
|
corpora: 'allDrives',
|
||||||
|
includeItemsFromAllDrives: 'true',
|
||||||
|
supportsAllDrives: 'true',
|
||||||
|
});
|
||||||
|
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const files = ((await r.json()) as { files?: DriveDocHit[] }).files || [];
|
||||||
|
let best: { file: DriveDocHit; score: number } | null = null;
|
||||||
|
for (const f of files) {
|
||||||
|
const fn = normalizeName(f.name || '');
|
||||||
|
let score = 0;
|
||||||
|
for (const h of cleaned) {
|
||||||
|
const hn = normalizeName(h);
|
||||||
|
if (!hn) continue;
|
||||||
|
if (fn === hn) score = Math.max(score, 100);
|
||||||
|
else if (fn.includes(hn) || hn.includes(fn)) score = Math.max(score, 90);
|
||||||
|
else {
|
||||||
|
const tokens = hn
|
||||||
|
.split(' ')
|
||||||
|
.filter((t) => t.length > 3 && t !== 'seguimiento' && t !== 'semanal');
|
||||||
|
const hits = tokens.filter((t) => fn.includes(t)).length;
|
||||||
|
if (tokens.length && hits / tokens.length >= 0.6) {
|
||||||
|
score = Math.max(score, 70 + hits * 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best || score > best.score) best = { file: f, score };
|
||||||
|
}
|
||||||
|
return best && best.score >= 70 ? best.file : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportGoogleDocPlain(fileId: string): Promise<string> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=text%2Fplain`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Drive export ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return await r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayRowsFromText(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
text: string;
|
||||||
|
fileId: string;
|
||||||
|
browserLink: string | null;
|
||||||
|
maxEntries: number;
|
||||||
|
now: string;
|
||||||
|
}): SeguimientoSyncRow[] {
|
||||||
|
const sections = splitNotesByDay(opts.text).slice(0, opts.maxEntries);
|
||||||
|
return sections.map((sec) => {
|
||||||
|
const metrics = parseMetricsFromMarkdown(sec.body);
|
||||||
|
const summaryBits = [
|
||||||
|
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
||||||
|
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
||||||
|
metrics.leads != null ? `Leads: ${metrics.leads}` : null,
|
||||||
|
metrics.cpl != null ? `CPL≈${metrics.cpl.toFixed(1)}€` : null,
|
||||||
|
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const firstLine =
|
||||||
|
sec.body
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||||
|
.find((l) => l.length > 8) || '';
|
||||||
|
return {
|
||||||
|
tenant_id: opts.tenantId,
|
||||||
|
source: 'superhuman' as const,
|
||||||
|
entry_type: 'meeting' as const,
|
||||||
|
external_id: `gdoc:${opts.fileId}:${sec.dateIso}`,
|
||||||
|
title: `Seguimiento · ${sec.label}`,
|
||||||
|
summary:
|
||||||
|
summaryBits.length > 0
|
||||||
|
? summaryBits.join(' · ')
|
||||||
|
: firstLine.slice(0, 220) || null,
|
||||||
|
body_md: excerpt(sec.body, 6000),
|
||||||
|
browser_link: opts.browserLink,
|
||||||
|
metrics,
|
||||||
|
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||||
|
synced_at: opts.now,
|
||||||
|
updated_at: opts.now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildSeguimientoRowsForDoc(opts: {
|
export async function buildSeguimientoRowsForDoc(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
tenantName?: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
maxEntries?: number;
|
maxEntries?: number;
|
||||||
exportBodies?: boolean;
|
exportBodies?: boolean;
|
||||||
}): Promise<SeguimientoSyncRow[]> {
|
}): Promise<SeguimientoSyncRow[]> {
|
||||||
const maxEntries = opts.maxEntries ?? 30;
|
const maxEntries = opts.maxEntries ?? 80;
|
||||||
const exportBodies = opts.exportBodies !== false;
|
const exportBodies = opts.exportBodies !== false;
|
||||||
const pages = await listPages(opts.docId);
|
const pages = await listPages(opts.docId);
|
||||||
const { entries } = selectClientFacingPages(pages);
|
const { roots, entries } = selectClientFacingPages(pages);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const rows: SeguimientoSyncRow[] = [];
|
const rows: SeguimientoSyncRow[] = [];
|
||||||
|
|
||||||
for (const { page, underSeguimiento } of entries.slice(0, maxEntries)) {
|
// 1) Google Doc embebido / emparejado por nombre (fuente real de notas por día)
|
||||||
let body: string | null = null;
|
const nameHints = [
|
||||||
let metrics: ParsedMetrics = {};
|
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
||||||
if (exportBodies && !SEGUIMIENTO_RE.test(page.name)) {
|
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
||||||
// Export leaf/topic pages (skip huge root containers)
|
opts.tenantName ? `Seguimiento ${opts.tenantName}` : '',
|
||||||
body = await exportPageMarkdown(opts.docId, page.id);
|
].filter(Boolean);
|
||||||
if (body) {
|
|
||||||
metrics = parseMetricsFromMarkdown(body);
|
if (exportBodies && nameHints.length) {
|
||||||
body = excerpt(body);
|
try {
|
||||||
}
|
const hit = await findSeguimientoGoogleDoc(nameHints);
|
||||||
} else if (exportBodies && KPI_RE.test(page.name)) {
|
if (hit) {
|
||||||
body = await exportPageMarkdown(opts.docId, page.id);
|
const text = await exportGoogleDocPlain(hit.id);
|
||||||
if (body) {
|
rows.push(
|
||||||
metrics = parseMetricsFromMarkdown(body);
|
...dayRowsFromText({
|
||||||
body = excerpt(body, 2000);
|
tenantId: opts.tenantId,
|
||||||
|
text,
|
||||||
|
fileId: hit.id,
|
||||||
|
browserLink:
|
||||||
|
hit.webViewLink ||
|
||||||
|
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||||
|
maxEntries,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to canvas pages
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryBits = [
|
|
||||||
page.subtitle?.trim(),
|
|
||||||
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
|
||||||
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
|
||||||
metrics.cpl != null ? `CPL≈${metrics.cpl.toFixed(1)}€` : null,
|
|
||||||
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
tenant_id: opts.tenantId,
|
|
||||||
source: 'superhuman',
|
|
||||||
entry_type: classifyEntry(page.name, underSeguimiento),
|
|
||||||
external_id: page.id,
|
|
||||||
title: page.name,
|
|
||||||
summary: summaryBits.length ? summaryBits.join(' · ') : null,
|
|
||||||
body_md: body,
|
|
||||||
browser_link: page.browserLink || null,
|
|
||||||
metrics,
|
|
||||||
occurred_at: page.updatedAt || page.createdAt || null,
|
|
||||||
synced_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
// 2) Si no hubo días desde Drive, intentar export canvas hijos (legacy)
|
||||||
|
if (!rows.length) {
|
||||||
|
for (const { page, underSeguimiento } of entries.slice(
|
||||||
|
0,
|
||||||
|
Math.min(maxEntries, 40)
|
||||||
|
)) {
|
||||||
|
let body: string | null = null;
|
||||||
|
let metrics: ParsedMetrics = {};
|
||||||
|
const isSegRoot = SEGUIMIENTO_RE.test(page.name);
|
||||||
|
const isEmbed = page.contentType === 'embed';
|
||||||
|
if (exportBodies && !isSegRoot && !isEmbed) {
|
||||||
|
body = await exportPageMarkdown(opts.docId, page.id);
|
||||||
|
if (body) {
|
||||||
|
metrics = parseMetricsFromMarkdown(body);
|
||||||
|
body = excerpt(body);
|
||||||
|
}
|
||||||
|
} else if (exportBodies && KPI_RE.test(page.name) && !isEmbed) {
|
||||||
|
body = await exportPageMarkdown(opts.docId, page.id);
|
||||||
|
if (body) {
|
||||||
|
metrics = parseMetricsFromMarkdown(body);
|
||||||
|
body = excerpt(body, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryBits = [
|
||||||
|
page.subtitle?.trim(),
|
||||||
|
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
||||||
|
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
||||||
|
metrics.cpl != null ? `CPL≈${metrics.cpl.toFixed(1)}€` : null,
|
||||||
|
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
tenant_id: opts.tenantId,
|
||||||
|
source: 'superhuman',
|
||||||
|
entry_type: classifyEntry(page.name, underSeguimiento),
|
||||||
|
external_id: page.id,
|
||||||
|
title: page.name,
|
||||||
|
summary: summaryBits.length ? summaryBits.join(' · ') : null,
|
||||||
|
body_md: body,
|
||||||
|
browser_link: page.browserLink || null,
|
||||||
|
metrics,
|
||||||
|
occurred_at: page.updatedAt || page.createdAt || null,
|
||||||
|
synced_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const ta = Date.parse(a.occurred_at || '') || 0;
|
||||||
|
const tb = Date.parse(b.occurred_at || '') || 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.slice(0, maxEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSeguimientoRows(
|
export async function upsertSeguimientoRows(
|
||||||
@@ -386,20 +625,24 @@ export async function upsertSeguimientoRows(
|
|||||||
if (!rows.length) return 0;
|
if (!rows.length) return 0;
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
if (!headers) throw new Error('no service role');
|
if (!headers) throw new Error('no service role');
|
||||||
const r = await fetch(
|
const chunk = 40;
|
||||||
`${supabaseUrl()}/rest/v1/seguimiento_entries?on_conflict=tenant_id,source,external_id`,
|
for (let i = 0; i < rows.length; i += chunk) {
|
||||||
{
|
const part = rows.slice(i, i + chunk);
|
||||||
method: 'POST',
|
const r = await fetch(
|
||||||
headers: {
|
`${supabaseUrl()}/rest/v1/seguimiento_entries?on_conflict=tenant_id,source,external_id`,
|
||||||
...headers,
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
headers: {
|
||||||
},
|
...headers,
|
||||||
body: JSON.stringify(rows),
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(part),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`upsert seguimiento: ${await r.text()}`);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
if (!r.ok) {
|
|
||||||
throw new Error(`upsert seguimiento: ${await r.text()}`);
|
|
||||||
}
|
}
|
||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
@@ -421,7 +664,6 @@ export async function patchSuperhumanIntegration(
|
|||||||
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
// Ensure row exists then retry
|
|
||||||
await fetch(`${supabaseUrl()}/rest/v1/integrations`, {
|
await fetch(`${supabaseUrl()}/rest/v1/integrations`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user