OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed. Co-authored-by: Cursor <cursoragent@cursor.com>
756 lines
23 KiB
TypeScript
756 lines
23 KiB
TypeScript
/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */
|
|
|
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
|
|
|
export const DRIVE_PROVIDER = 'google_drive';
|
|
|
|
/** Unified agency Google scopes: Drive + GA4 + Search Console + Ads. */
|
|
export const DRIVE_SCOPES = [
|
|
'https://www.googleapis.com/auth/drive',
|
|
'https://www.googleapis.com/auth/analytics.readonly',
|
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
'https://www.googleapis.com/auth/adwords',
|
|
'openid',
|
|
'email',
|
|
'profile',
|
|
].join(' ');
|
|
|
|
export type DriveFolder = { id: string; name: string };
|
|
|
|
export type MatchResult = {
|
|
tenantId: string;
|
|
tenantName: string;
|
|
tenantSlug: string;
|
|
folderId: string | null;
|
|
folderName: string | null;
|
|
score: number;
|
|
status: 'matched' | 'ambiguous' | 'unmatched';
|
|
candidates?: { id: string; name: string; score: number }[];
|
|
};
|
|
|
|
function appUrl(): string {
|
|
return (
|
|
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
|
|
process.env.APP_URL?.trim() ||
|
|
'https://app.4ventures.es'
|
|
);
|
|
}
|
|
|
|
export function driveConfigured(): boolean {
|
|
return Boolean(
|
|
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim() &&
|
|
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim()
|
|
);
|
|
}
|
|
|
|
export function driveRedirectUri(): string {
|
|
return (
|
|
process.env.GOOGLE_OAUTH_REDIRECT_URI?.trim() ||
|
|
`${appUrl()}/api/admin/drive/callback`
|
|
);
|
|
}
|
|
|
|
export function driveAuthUrl(state: string): string {
|
|
const params = new URLSearchParams({
|
|
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
|
|
redirect_uri: driveRedirectUri(),
|
|
response_type: 'code',
|
|
scope: DRIVE_SCOPES,
|
|
access_type: 'offline',
|
|
prompt: 'consent',
|
|
state,
|
|
});
|
|
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
}
|
|
|
|
export async function exchangeCode(code: string): Promise<{
|
|
access_token: string;
|
|
refresh_token?: string;
|
|
expires_in: number;
|
|
scope?: string;
|
|
}> {
|
|
const body = new URLSearchParams({
|
|
code,
|
|
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
|
|
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!.trim(),
|
|
redirect_uri: driveRedirectUri(),
|
|
grant_type: 'authorization_code',
|
|
});
|
|
const r = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!r.ok) throw new Error(`token exchange: ${(await r.text()).slice(0, 300)}`);
|
|
return r.json();
|
|
}
|
|
|
|
async function refreshAccessToken(refreshToken: string): Promise<{
|
|
access_token: string;
|
|
expires_in: number;
|
|
}> {
|
|
const body = new URLSearchParams({
|
|
refresh_token: refreshToken,
|
|
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
|
|
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!.trim(),
|
|
grant_type: 'refresh_token',
|
|
});
|
|
const r = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!r.ok) throw new Error(`token refresh: ${(await r.text()).slice(0, 300)}`);
|
|
return r.json();
|
|
}
|
|
|
|
export async function fetchGoogleEmail(accessToken: string): Promise<string | null> {
|
|
const r = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
cache: 'no-store',
|
|
});
|
|
if (!r.ok) return null;
|
|
const j = (await r.json()) as { email?: string };
|
|
return j.email || null;
|
|
}
|
|
|
|
export async function saveAgencyOAuth(opts: {
|
|
accessToken: string;
|
|
refreshToken?: string | null;
|
|
expiresIn: number;
|
|
scopes?: string;
|
|
email?: string | null;
|
|
}): Promise<void> {
|
|
const headers = serviceHeaders();
|
|
if (!headers) throw new Error('no service role');
|
|
const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
|
|
const row: Record<string, unknown> = {
|
|
provider: DRIVE_PROVIDER,
|
|
access_token: opts.accessToken,
|
|
expires_at: expiresAt,
|
|
scopes: opts.scopes || DRIVE_SCOPES,
|
|
account_email: opts.email || null,
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
if (opts.refreshToken) row.refresh_token = opts.refreshToken;
|
|
|
|
// PostgREST PATCH returns 204 even when 0 rows match — do not treat that as success.
|
|
// Upsert on primary key `provider`.
|
|
const upsert = await fetch(
|
|
`${supabaseUrl()}/rest/v1/agency_oauth?on_conflict=provider`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'resolution=merge-duplicates,return=representation',
|
|
},
|
|
body: JSON.stringify(row),
|
|
}
|
|
);
|
|
if (!upsert.ok) {
|
|
throw new Error(`save oauth: ${(await upsert.text()).slice(0, 300)}`);
|
|
}
|
|
const saved = (await upsert.json()) as unknown[];
|
|
if (!Array.isArray(saved) || saved.length === 0) {
|
|
throw new Error('save oauth: upsert no escribió filas');
|
|
}
|
|
}
|
|
|
|
type OAuthRow = {
|
|
access_token: string;
|
|
refresh_token?: string | null;
|
|
expires_at?: string | null;
|
|
account_email?: string | null;
|
|
};
|
|
|
|
export async function getAgencyOAuthStatus(): Promise<{
|
|
connected: boolean;
|
|
email?: string | null;
|
|
expiresAt?: string | null;
|
|
configured: boolean;
|
|
scopes?: string | null;
|
|
hasAnalytics?: boolean;
|
|
hasSearchConsole?: boolean;
|
|
hasDrive?: boolean;
|
|
hasAds?: boolean;
|
|
}> {
|
|
const configured = driveConfigured();
|
|
const headers = serviceHeaders();
|
|
if (!headers) return { connected: false, configured };
|
|
const r = await fetch(
|
|
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token,scopes`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!r.ok) {
|
|
return {
|
|
connected: false,
|
|
configured,
|
|
};
|
|
}
|
|
const rows = (await r.json()) as (OAuthRow & { scopes?: string | null })[];
|
|
const row = rows[0];
|
|
if (!row?.access_token && !row?.refresh_token) {
|
|
return { connected: false, configured };
|
|
}
|
|
const scopes = row.scopes || '';
|
|
return {
|
|
connected: true,
|
|
email: row.account_email,
|
|
expiresAt: row.expires_at,
|
|
configured,
|
|
scopes,
|
|
hasAnalytics: scopes.includes('analytics'),
|
|
hasSearchConsole:
|
|
scopes.includes('webmasters') || scopes.includes('searchconsole'),
|
|
hasDrive: scopes.includes('drive') || !scopes,
|
|
hasAds:
|
|
scopes.includes('adwords') ||
|
|
scopes.includes('https://www.googleapis.com/auth/adwords'),
|
|
};
|
|
}
|
|
|
|
/** Valid access token (refresh if needed). */
|
|
export async function getValidAccessToken(): Promise<string> {
|
|
const headers = serviceHeaders();
|
|
if (!headers) throw new Error('no service role');
|
|
if (!driveConfigured()) throw new Error('GOOGLE_OAUTH_CLIENT_ID/SECRET no configurados');
|
|
|
|
const r = await fetch(
|
|
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=*`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!r.ok) throw new Error(`agency_oauth: ${(await r.text()).slice(0, 200)}`);
|
|
const rows = (await r.json()) as OAuthRow[];
|
|
const row = rows[0];
|
|
if (!row) throw new Error('Drive no conectado. Conecta marketing@ en Agencia.');
|
|
|
|
const expires = row.expires_at ? new Date(row.expires_at).getTime() : 0;
|
|
if (row.access_token && expires > Date.now() + 60_000) {
|
|
return row.access_token;
|
|
}
|
|
if (!row.refresh_token) {
|
|
if (row.access_token) return row.access_token;
|
|
throw new Error('Sin refresh_token; vuelve a conectar Drive');
|
|
}
|
|
const refreshed = await refreshAccessToken(row.refresh_token);
|
|
await saveAgencyOAuth({
|
|
accessToken: refreshed.access_token,
|
|
refreshToken: row.refresh_token,
|
|
expiresIn: refreshed.expires_in,
|
|
email: row.account_email,
|
|
});
|
|
return refreshed.access_token;
|
|
}
|
|
|
|
export function normalizeName(raw: string): string {
|
|
return raw
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
export function scoreFolderToTenant(
|
|
folderName: string,
|
|
tenantName: string,
|
|
tenantSlug: string
|
|
): number {
|
|
// «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix
|
|
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
|
|
const candidates = [folderName, stripped];
|
|
const aliasMap: Record<string, string[]> = {
|
|
bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'],
|
|
'wine advisors': ['wine advisor', 'wineadvisor'],
|
|
'de tu a tu formacion': ['detuatu', 'de tu a tu'],
|
|
'clinica dental sonrisas': ['sonrisas', 'clinica sonrisas'],
|
|
};
|
|
let best = 0;
|
|
for (const raw of candidates) {
|
|
const f = normalizeName(raw);
|
|
const n = normalizeName(tenantName);
|
|
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
|
if (!f || (!n && !s)) continue;
|
|
if (f === n || f === s) best = Math.max(best, 100);
|
|
else if (n && (f.includes(n) || n.includes(f))) best = Math.max(best, 85);
|
|
else if (s && (f.includes(s) || s.includes(f))) best = Math.max(best, 80);
|
|
else {
|
|
const aliases = aliasMap[n] || aliasMap[s] || [];
|
|
for (const a of aliases) {
|
|
if (f.includes(a) || a.includes(f)) best = Math.max(best, 90);
|
|
}
|
|
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
|
const nt = new Set(
|
|
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
|
);
|
|
if (ft.size && nt.size) {
|
|
let hit = 0;
|
|
for (const t of nt) if (ft.has(t)) hit += 1;
|
|
const ratio = hit / nt.size;
|
|
if (ratio >= 0.8) best = Math.max(best, 70);
|
|
else if (ratio >= 0.5) best = Math.max(best, 55);
|
|
else best = Math.max(best, Math.round(ratio * 50));
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export async function listDriveFolders(): Promise<DriveFolder[]> {
|
|
/** Prefer children of agency folder «Clientes»; fallback to all folders. */
|
|
const under = await listClientFoldersUnderClientes();
|
|
if (under.folders.length) return under.folders;
|
|
return listAllDriveFolders();
|
|
}
|
|
|
|
/** All folders visible to marketing@ (legacy / fallback). */
|
|
export async function listAllDriveFolders(): Promise<DriveFolder[]> {
|
|
const token = await getValidAccessToken();
|
|
const out: DriveFolder[] = [];
|
|
let pageToken: string | undefined;
|
|
do {
|
|
const params = new URLSearchParams({
|
|
q: "mimeType='application/vnd.google-apps.folder' and trashed=false",
|
|
fields: 'nextPageToken,files(id,name)',
|
|
pageSize: '1000',
|
|
corpora: 'allDrives',
|
|
includeItemsFromAllDrives: 'true',
|
|
supportsAllDrives: 'true',
|
|
});
|
|
if (pageToken) params.set('pageToken', pageToken);
|
|
const r = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files?${params}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (!r.ok) throw new Error(`Drive list: ${(await r.text()).slice(0, 300)}`);
|
|
const j = (await r.json()) as {
|
|
files?: DriveFolder[];
|
|
nextPageToken?: string;
|
|
};
|
|
out.push(...(j.files || []));
|
|
pageToken = j.nextPageToken;
|
|
} while (pageToken);
|
|
return out;
|
|
}
|
|
|
|
/** Find the agency «Clientes» root (prefer «03-Clientes»). */
|
|
export async function findClientesRootFolder(): Promise<DriveFolder | null> {
|
|
const token = await getValidAccessToken();
|
|
const preferred = [
|
|
'03-Clientes',
|
|
'Clientes',
|
|
'01.Clientes - Documentos maestros',
|
|
];
|
|
for (const name of preferred) {
|
|
const safe = name.replace(/'/g, "\\'");
|
|
const params = new URLSearchParams({
|
|
q: `name='${safe}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
|
|
fields: 'files(id,name)',
|
|
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 j = (await r.json()) as { files?: DriveFolder[] };
|
|
const hit = (j.files || []).find((f) => f.name === name);
|
|
if (hit) return hit;
|
|
}
|
|
|
|
// Fallback: any folder whose name contains «Clientes» with many children later
|
|
const params = new URLSearchParams({
|
|
q: "name contains 'Clientes' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
|
fields: 'files(id,name)',
|
|
pageSize: '20',
|
|
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 j = (await r.json()) as { files?: DriveFolder[] };
|
|
const files = (j.files || []).filter(
|
|
(f) => /clientes/i.test(f.name) && !/opensalud|documentos maestros/i.test(f.name)
|
|
);
|
|
// Prefer 03-Clientes style
|
|
return (
|
|
files.find((f) => /^0?\d+[-.]?\s*clientes$/i.test(f.name)) ||
|
|
files.find((f) => /clientes/i.test(f.name)) ||
|
|
null
|
|
);
|
|
}
|
|
|
|
/** Direct child folders of «Clientes» — the only valid tenant roots. */
|
|
export async function listClientFoldersUnderClientes(): Promise<{
|
|
clientesRoot: DriveFolder | null;
|
|
folders: DriveFolder[];
|
|
}> {
|
|
const root = await findClientesRootFolder();
|
|
if (!root) return { clientesRoot: null, folders: [] };
|
|
const children = await listFilesInFolder(root.id);
|
|
const folders = children
|
|
.filter((f) => f.mimeType === 'application/vnd.google-apps.folder')
|
|
.map((f) => ({ id: f.id, name: f.name }));
|
|
return { clientesRoot: root, folders };
|
|
}
|
|
|
|
/** Walk parents to check fileId is rootId or a descendant. */
|
|
export async function isUnderDriveFolder(
|
|
fileId: string,
|
|
rootId: string,
|
|
maxHops = 24
|
|
): Promise<boolean> {
|
|
if (!fileId || !rootId) return false;
|
|
if (fileId === rootId) return true;
|
|
const token = await getValidAccessToken();
|
|
let cur = fileId;
|
|
for (let i = 0; i < maxHops; i++) {
|
|
const r = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(cur)}?fields=id,parents&supportsAllDrives=true`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (!r.ok) return false;
|
|
const j = (await r.json()) as { parents?: string[] };
|
|
const parents = j.parents || [];
|
|
if (!parents.length) return false;
|
|
if (parents.includes(rootId)) return true;
|
|
cur = parents[0];
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function getDriveFileMeta(
|
|
fileId: string
|
|
): Promise<{ id: string; name: string; parents?: string[] } | null> {
|
|
const token = await getValidAccessToken();
|
|
const r = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=id,name,parents&supportsAllDrives=true`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (!r.ok) return null;
|
|
return r.json();
|
|
}
|
|
|
|
export async function matchFoldersToTenants(
|
|
tenants: { id: string; name: string; slug: string }[],
|
|
folders: DriveFolder[]
|
|
): Promise<MatchResult[]> {
|
|
const THRESHOLD = 55;
|
|
const AMBIGUOUS_GAP = 8;
|
|
|
|
type Scored = { folder: DriveFolder; tenantId: string; score: number };
|
|
const all: Scored[] = [];
|
|
for (const t of tenants) {
|
|
for (const f of folders) {
|
|
const score = scoreFolderToTenant(f.name, t.name, t.slug);
|
|
if (score >= THRESHOLD) {
|
|
all.push({ folder: f, tenantId: t.id, score });
|
|
}
|
|
}
|
|
}
|
|
all.sort((a, b) => b.score - a.score);
|
|
|
|
const usedFolders = new Set<string>();
|
|
const usedTenants = new Set<string>();
|
|
const assigned = new Map<string, { folder: DriveFolder; score: number }>();
|
|
|
|
for (const row of all) {
|
|
if (usedFolders.has(row.folder.id) || usedTenants.has(row.tenantId)) continue;
|
|
// check ambiguity: another folder for same tenant within gap
|
|
const rivals = all.filter(
|
|
(x) =>
|
|
x.tenantId === row.tenantId &&
|
|
x.folder.id !== row.folder.id &&
|
|
!usedFolders.has(x.folder.id) &&
|
|
row.score - x.score < AMBIGUOUS_GAP &&
|
|
x.score >= THRESHOLD
|
|
);
|
|
if (rivals.length) continue; // leave for ambiguous review
|
|
assigned.set(row.tenantId, { folder: row.folder, score: row.score });
|
|
usedFolders.add(row.folder.id);
|
|
usedTenants.add(row.tenantId);
|
|
}
|
|
|
|
return tenants.map((t) => {
|
|
const hit = assigned.get(t.id);
|
|
if (hit) {
|
|
return {
|
|
tenantId: t.id,
|
|
tenantName: t.name,
|
|
tenantSlug: t.slug,
|
|
folderId: hit.folder.id,
|
|
folderName: hit.folder.name,
|
|
score: hit.score,
|
|
status: 'matched' as const,
|
|
};
|
|
}
|
|
const cands = all
|
|
.filter((x) => x.tenantId === t.id)
|
|
.slice(0, 5)
|
|
.map((x) => ({ id: x.folder.id, name: x.folder.name, score: x.score }));
|
|
if (cands.length) {
|
|
return {
|
|
tenantId: t.id,
|
|
tenantName: t.name,
|
|
tenantSlug: t.slug,
|
|
folderId: null,
|
|
folderName: null,
|
|
score: cands[0].score,
|
|
status: 'ambiguous' as const,
|
|
candidates: cands,
|
|
};
|
|
}
|
|
return {
|
|
tenantId: t.id,
|
|
tenantName: t.name,
|
|
tenantSlug: t.slug,
|
|
folderId: null,
|
|
folderName: null,
|
|
score: 0,
|
|
status: 'unmatched' as const,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function applyMatches(
|
|
matches: MatchResult[]
|
|
): Promise<{ linked: number }> {
|
|
const headers = serviceHeaders();
|
|
if (!headers) throw new Error('no service role');
|
|
const base = supabaseUrl();
|
|
let linked = 0;
|
|
for (const m of matches) {
|
|
if (m.status !== 'matched' || !m.folderId) continue;
|
|
const patch = {
|
|
status: 'connected',
|
|
external_account_id: m.folderId,
|
|
label: m.folderName || 'Google Drive',
|
|
last_sync_at: new Date().toISOString(),
|
|
error_message: null,
|
|
meta: {
|
|
folder_name: m.folderName,
|
|
match_score: m.score,
|
|
matched_at: new Date().toISOString(),
|
|
},
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(m.tenantId)}&provider=eq.${DRIVE_PROVIDER}`;
|
|
let r = await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=minimal',
|
|
},
|
|
body: JSON.stringify(patch),
|
|
});
|
|
if (!r.ok) {
|
|
r = await fetch(`${base}/rest/v1/integrations`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'resolution=merge-duplicates',
|
|
},
|
|
body: JSON.stringify({
|
|
tenant_id: m.tenantId,
|
|
provider: DRIVE_PROVIDER,
|
|
...patch,
|
|
}),
|
|
});
|
|
}
|
|
if (r.ok) linked += 1;
|
|
}
|
|
return { linked };
|
|
}
|
|
|
|
export async function getTenantDriveFolderId(
|
|
tenantId: string
|
|
): Promise<{ folderId: string; folderName?: string } | null> {
|
|
const headers = serviceHeaders();
|
|
if (!headers) return null;
|
|
const r = await fetch(
|
|
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${DRIVE_PROVIDER}&select=status,external_account_id,label,meta`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!r.ok) return null;
|
|
const rows = await r.json();
|
|
const row = rows[0];
|
|
if (!row || row.status !== 'connected' || !row.external_account_id) return null;
|
|
return {
|
|
folderId: row.external_account_id,
|
|
folderName: row.meta?.folder_name || row.label || undefined,
|
|
};
|
|
}
|
|
|
|
export async function listFilesInFolder(folderId: string): Promise<
|
|
{
|
|
id: string;
|
|
name: string;
|
|
mimeType?: string;
|
|
size?: string;
|
|
modifiedTime?: string;
|
|
webViewLink?: string;
|
|
}[]
|
|
> {
|
|
const token = await getValidAccessToken();
|
|
const out: {
|
|
id: string;
|
|
name: string;
|
|
mimeType?: string;
|
|
size?: string;
|
|
modifiedTime?: string;
|
|
webViewLink?: string;
|
|
}[] = [];
|
|
let pageToken: string | undefined;
|
|
do {
|
|
const params = new URLSearchParams({
|
|
q: `'${folderId}' in parents and trashed=false`,
|
|
fields:
|
|
'nextPageToken,files(id,name,mimeType,size,modifiedTime,webViewLink)',
|
|
pageSize: '200',
|
|
supportsAllDrives: 'true',
|
|
includeItemsFromAllDrives: 'true',
|
|
});
|
|
if (pageToken) params.set('pageToken', pageToken);
|
|
const r = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files?${params}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (!r.ok) throw new Error(`Drive children: ${(await r.text()).slice(0, 300)}`);
|
|
const j = await r.json();
|
|
out.push(...(j.files || []));
|
|
pageToken = j.nextPageToken;
|
|
} while (pageToken);
|
|
return out;
|
|
}
|
|
|
|
/** Find or create a child folder by name under parent. */
|
|
export async function ensureChildFolder(
|
|
parentId: string,
|
|
name: string
|
|
): Promise<string> {
|
|
const token = await getValidAccessToken();
|
|
const safe = name.replace(/'/g, "\\'");
|
|
const q = `name='${safe}' and mimeType='application/vnd.google-apps.folder' and '${parentId}' in parents and trashed=false`;
|
|
const params = new URLSearchParams({
|
|
q,
|
|
fields: 'files(id,name)',
|
|
pageSize: '1',
|
|
supportsAllDrives: 'true',
|
|
includeItemsFromAllDrives: 'true',
|
|
});
|
|
const find = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files?${params}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (find.ok) {
|
|
const j = await find.json();
|
|
if (j.files?.[0]?.id) return j.files[0].id;
|
|
}
|
|
const create = await fetch(
|
|
'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
name,
|
|
mimeType: 'application/vnd.google-apps.folder',
|
|
parents: [parentId],
|
|
}),
|
|
}
|
|
);
|
|
if (!create.ok) {
|
|
throw new Error(`create folder: ${(await create.text()).slice(0, 300)}`);
|
|
}
|
|
const created = await create.json();
|
|
return created.id as string;
|
|
}
|
|
|
|
export async function uploadToDrive(opts: {
|
|
parentFolderId: string;
|
|
fileName: string;
|
|
mimeType: string;
|
|
buffer: Buffer;
|
|
}): Promise<{ id: string; webViewLink?: string }> {
|
|
const token = await getValidAccessToken();
|
|
const metadata = {
|
|
name: opts.fileName,
|
|
parents: [opts.parentFolderId],
|
|
};
|
|
const boundary = `4v_${Date.now()}`;
|
|
const metaPart = JSON.stringify(metadata);
|
|
const body = Buffer.concat([
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metaPart}\r\n`
|
|
),
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Type: ${opts.mimeType}\r\n\r\n`
|
|
),
|
|
opts.buffer,
|
|
Buffer.from(`\r\n--${boundary}--`),
|
|
]);
|
|
|
|
const r = await fetch(
|
|
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,webViewLink',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': `multipart/related; boundary=${boundary}`,
|
|
},
|
|
body,
|
|
}
|
|
);
|
|
if (!r.ok) throw new Error(`Drive upload: ${(await r.text()).slice(0, 300)}`);
|
|
return r.json();
|
|
}
|
|
|
|
export async function getDriveDownloadUrl(fileId: string): Promise<string> {
|
|
const token = await getValidAccessToken();
|
|
// Prefer webContentLink via metadata; else use alt=media with token (short-lived via redirect proxy)
|
|
const r = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=webViewLink,webContentLink,mimeType&supportsAllDrives=true`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (!r.ok) throw new Error(`Drive file: ${(await r.text()).slice(0, 200)}`);
|
|
const j = await r.json();
|
|
return j.webContentLink || j.webViewLink || `https://drive.google.com/file/d/${fileId}/view`;
|
|
}
|