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:
@@ -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 { matchAccountsToTenants, normalizeName, scoreNameToTenant } from '@/lib/match';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { getValidAccessToken } from '@/lib/google-drive';
|
||||
|
||||
export const SUPERHUMAN_PROVIDER = 'superhuman_docs' as const;
|
||||
export const SUPERHUMAN_API_BASE = 'https://docs.superhuman.com/apis/v1';
|
||||
@@ -20,8 +24,10 @@ export type ShPage = {
|
||||
browserLink?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
contentType?: string;
|
||||
parent?: { id: string; name?: string } | null;
|
||||
children?: { id: string; name?: string }[];
|
||||
icon?: { name?: string } | null;
|
||||
};
|
||||
|
||||
export type ParsedMetrics = {
|
||||
@@ -48,6 +54,14 @@ export type SeguimientoSyncRow = {
|
||||
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 {
|
||||
return process.env.SUPERHUMAN_DOCS_API_KEY?.trim() || null;
|
||||
}
|
||||
@@ -153,6 +167,55 @@ export function parseMetricsFromMarkdown(md: string): ParsedMetrics {
|
||||
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(
|
||||
docId: string,
|
||||
pageId: string,
|
||||
@@ -205,17 +268,17 @@ function excerpt(md: string, max = 1400): string {
|
||||
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 (/reuni[oó]n|kick\s*off|seguimiento semanal/i.test(title)) return 'meeting';
|
||||
if (underSeguimiento) return 'topic';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
function pageAncestors(
|
||||
page: ShPage,
|
||||
byId: Map<string, ShPage>
|
||||
): ShPage[] {
|
||||
function pageAncestors(page: ShPage, byId: Map<string, ShPage>): ShPage[] {
|
||||
const chain: ShPage[] = [];
|
||||
let cur: ShPage | undefined = page;
|
||||
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) => 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 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)) {
|
||||
// Skip deep password children already filtered; skip empty password roots
|
||||
if (isPasswordSensitiveName(page.name)) continue;
|
||||
entries.push({
|
||||
page,
|
||||
@@ -262,7 +328,6 @@ export function selectClientFacingPages(pages: ShPage[]): {
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer freshest first; cap later at sync
|
||||
entries.sort((a, b) => {
|
||||
const ta = Date.parse(a.page.updatedAt || a.page.createdAt || '') || 0;
|
||||
const tb = Date.parse(b.page.updatedAt || b.page.createdAt || '') || 0;
|
||||
@@ -322,62 +387,236 @@ export function bestDocForTenant(
|
||||
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: {
|
||||
tenantId: string;
|
||||
tenantName?: string;
|
||||
docId: string;
|
||||
maxEntries?: number;
|
||||
exportBodies?: boolean;
|
||||
}): Promise<SeguimientoSyncRow[]> {
|
||||
const maxEntries = opts.maxEntries ?? 30;
|
||||
const maxEntries = opts.maxEntries ?? 80;
|
||||
const exportBodies = opts.exportBodies !== false;
|
||||
const pages = await listPages(opts.docId);
|
||||
const { entries } = selectClientFacingPages(pages);
|
||||
const { roots, entries } = selectClientFacingPages(pages);
|
||||
const now = new Date().toISOString();
|
||||
const rows: SeguimientoSyncRow[] = [];
|
||||
|
||||
for (const { page, underSeguimiento } of entries.slice(0, maxEntries)) {
|
||||
let body: string | null = null;
|
||||
let metrics: ParsedMetrics = {};
|
||||
if (exportBodies && !SEGUIMIENTO_RE.test(page.name)) {
|
||||
// Export leaf/topic pages (skip huge root containers)
|
||||
body = await exportPageMarkdown(opts.docId, page.id);
|
||||
if (body) {
|
||||
metrics = parseMetricsFromMarkdown(body);
|
||||
body = excerpt(body);
|
||||
}
|
||||
} else if (exportBodies && KPI_RE.test(page.name)) {
|
||||
body = await exportPageMarkdown(opts.docId, page.id);
|
||||
if (body) {
|
||||
metrics = parseMetricsFromMarkdown(body);
|
||||
body = excerpt(body, 2000);
|
||||
// 1) Google Doc embebido / emparejado por nombre (fuente real de notas por día)
|
||||
const nameHints = [
|
||||
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
||||
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
||||
opts.tenantName ? `Seguimiento ${opts.tenantName}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (exportBodies && nameHints.length) {
|
||||
try {
|
||||
const hit = await findSeguimientoGoogleDoc(nameHints);
|
||||
if (hit) {
|
||||
const text = await exportGoogleDocPlain(hit.id);
|
||||
rows.push(
|
||||
...dayRowsFromText({
|
||||
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(
|
||||
@@ -386,20 +625,24 @@ export async function upsertSeguimientoRows(
|
||||
if (!rows.length) return 0;
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/seguimiento_entries?on_conflict=tenant_id,source,external_id`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(rows),
|
||||
const chunk = 40;
|
||||
for (let i = 0; i < rows.length; i += chunk) {
|
||||
const part = rows.slice(i, i + chunk);
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/seguimiento_entries?on_conflict=tenant_id,source,external_id`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'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;
|
||||
}
|
||||
@@ -421,7 +664,6 @@ export async function patchSuperhumanIntegration(
|
||||
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
// Ensure row exists then retry
|
||||
await fetch(`${supabaseUrl()}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user