From ced2151b1d42a9d298b0a3117811ced616121c90 Mon Sep 17 00:00:00 2001 From: NotARoomba Date: Tue, 10 Mar 2026 14:18:52 -0400 Subject: [PATCH] unified checks --- backend/src/config.ts | 5 + backend/src/routes/admin.ts | 132 ++++++++++++++++++ frontend/src/routes/admin/+page.svelte | 180 ++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 1 deletion(-) diff --git a/backend/src/config.ts b/backend/src/config.ts index b543074..61877be 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -38,6 +38,11 @@ export const config = { airtableProjectsTableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!, airtableUsersTableId: process.env.AIRTABLE_USERS_TABLE_ID!, + // Unified Airtable + unifiedAirtableToken: process.env.UNIFIED_AIRTABLE_TOKEN, + unifiedAirtableBaseId: process.env.UNIFIED_AIRTABLE_BASE_ID, + unifiedAirtableTableId: process.env.UNIFIED_AIRTABLE_TABLE_ID, + // YSWS fraudToken: process.env.FRAUD_TOKEN, diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index dd395e6..af6e7ec 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -3651,6 +3651,138 @@ admin.post("/sync-ysws", async ({ headers }) => { } }); +// Check unified airtable for duplicates by Code URL / Playable URL and non-scraps YSWS +admin.get("/unified-duplicates", async ({ headers, status }) => { + const user = await requireAdmin(headers as Record); + if (!user) { + return status(401, { error: "Unauthorized" }); + } + + if (!config.unifiedAirtableToken || !config.unifiedAirtableBaseId || !config.unifiedAirtableTableId) { + return status(500, { error: "Unified Airtable not configured" }); + } + + try { + const Airtable = (await import('airtable')).default; + const airtable = new Airtable({ apiKey: config.unifiedAirtableToken }); + const base = airtable.base(config.unifiedAirtableBaseId); + const table = base(config.unifiedAirtableTableId); + + // Fetch all records from the unified airtable + const allRecords: { id: string; ysws: string; playableUrl: string; codeUrl: string }[] = []; + await new Promise((resolve, reject) => { + table.select({ + fields: ['YSWS', 'Playable URL', 'Code URL'] + }).eachPage( + (records, fetchNextPage) => { + for (const record of records) { + allRecords.push({ + id: record.id, + ysws: String(record.get('YSWS') || ''), + playableUrl: String(record.get('Playable URL') || ''), + codeUrl: String(record.get('Code URL') || '') + }); + } + fetchNextPage(); + }, + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }); + + // Get scraps projects' URLs from our DB for comparison + const scrapsProjects = await db + .select({ + id: projectsTable.id, + name: projectsTable.name, + githubUrl: projectsTable.githubUrl, + playableUrl: projectsTable.playableUrl, + status: projectsTable.status, + userId: projectsTable.userId, + }) + .from(projectsTable) + .where( + and( + or( + eq(projectsTable.status, "waiting_for_review"), + eq(projectsTable.status, "pending_admin_approval"), + eq(projectsTable.status, "shipped"), + eq(projectsTable.status, "in_progress"), + ), + or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), + ), + ); + + const scrapsCodeUrls = new Set(scrapsProjects.map(p => p.githubUrl).filter(Boolean)); + const scrapsPlayableUrls = new Set(scrapsProjects.map(p => p.playableUrl).filter(Boolean)); + + // Find records in the unified airtable that match scraps URLs but YSWS != "Scraps" + const nonScrapsMatches: { id: string; ysws: string; playableUrl: string; codeUrl: string; matchType: string }[] = []; + + for (const record of allRecords) { + if (record.ysws.toLowerCase() === 'scraps') continue; + + const matchTypes: string[] = []; + if (record.codeUrl && scrapsCodeUrls.has(record.codeUrl)) { + matchTypes.push('code_url'); + } + if (record.playableUrl && scrapsPlayableUrls.has(record.playableUrl)) { + matchTypes.push('playable_url'); + } + + if (matchTypes.length > 0) { + nonScrapsMatches.push({ + ...record, + matchType: matchTypes.join(', ') + }); + } + } + + // Also find duplicate URLs within the unified table itself + const codeUrlCounts = new Map(); + const playableUrlCounts = new Map(); + + for (const record of allRecords) { + if (record.codeUrl) { + const existing = codeUrlCounts.get(record.codeUrl) || []; + existing.push(record); + codeUrlCounts.set(record.codeUrl, existing); + } + if (record.playableUrl) { + const existing = playableUrlCounts.get(record.playableUrl) || []; + existing.push(record); + playableUrlCounts.set(record.playableUrl, existing); + } + } + + const duplicateCodeUrls: { url: string; records: typeof allRecords }[] = []; + for (const [url, records] of codeUrlCounts) { + if (records.length > 1) { + duplicateCodeUrls.push({ url, records }); + } + } + + const duplicatePlayableUrls: { url: string; records: typeof allRecords }[] = []; + for (const [url, records] of playableUrlCounts) { + if (records.length > 1) { + duplicatePlayableUrls.push({ url, records }); + } + } + + return { + totalRecords: allRecords.length, + nonScrapsMatches, + duplicateCodeUrls, + duplicatePlayableUrls + }; + } catch (err) { + console.error("[ADMIN] Unified duplicates check error:", err); + return status(500, { error: "Failed to check unified airtable" }); + } +}); + // Recalculate shop item pricing from current price/stock values (admin only) admin.post("/recalculate-shop-pricing", async ({ headers, status }) => { const user = await requireAdmin(headers as Record); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 9783f2f..aed6635 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -14,7 +14,8 @@ RefreshCw, ShoppingCart, DollarSign, - Calculator + Calculator, + SearchCheck } from '@lucide/svelte'; import { getUser } from '$lib/auth-client'; import { API_URL } from '$lib/config'; @@ -136,6 +137,23 @@ let pricingResult = $state<{ updatedCount: number } | null>(null); let pricingError = $state(null); + // Unified duplicates state + interface DuplicateRecord { + id: string; + ysws: string; + playableUrl: string; + codeUrl: string; + } + interface UnifiedDuplicatesResult { + totalRecords: number; + nonScrapsMatches: (DuplicateRecord & { matchType: string })[]; + duplicateCodeUrls: { url: string; records: DuplicateRecord[] }[]; + duplicatePlayableUrls: { url: string; records: DuplicateRecord[] }[]; + } + let dupChecking = $state(false); + let dupResult = $state(null); + let dupError = $state(null); + async function fetchPayoutInfo() { payoutLoading = true; try { @@ -273,6 +291,27 @@ } } + async function checkUnifiedDuplicates() { + dupChecking = true; + dupResult = null; + dupError = null; + try { + const res = await fetch(`${API_URL}/admin/unified-duplicates`, { + credentials: 'include' + }); + const data = await res.json(); + if (data.error) { + dupError = data.error; + } else { + dupResult = data; + } + } catch { + dupError = 'Failed to check unified airtable'; + } finally { + dupChecking = false; + } + } + async function downloadExport(endpoint: string, filename: string) { try { const res = await fetch(`${API_URL}/admin/export/${endpoint}`, { @@ -957,6 +996,145 @@ {/if} + +
+
+
+

+ + check unified airtable duplicates +

+

+ find projects submitted to other YSWS programs with the same code or playable URL +

+
+ +
+ + {#if dupError} +
{dupError}
+ {/if} + + {#if dupResult} +
+

+ scanned {dupResult.totalRecords.toLocaleString()} unified airtable records +

+ + {#if dupResult.nonScrapsMatches.length > 0} +
+

+ scraps URLs found in other YSWS programs ({dupResult.nonScrapsMatches.length}) +

+
+ {#each dupResult.nonScrapsMatches as match} +
+
+ {match.ysws} + matched by: {match.matchType} +
+ {#if match.codeUrl} +

+ code: + {match.codeUrl} +

+ {/if} + {#if match.playableUrl} +

+ playable: + {match.playableUrl} +

+ {/if} +
+ {/each} +
+
+ {:else} +
+

+ no scraps URLs found in other YSWS programs +

+
+ {/if} + + {#if dupResult.duplicateCodeUrls.length > 0} +
+

+ duplicate code URLs across YSWS ({dupResult.duplicateCodeUrls.length}) +

+
+ {#each dupResult.duplicateCodeUrls as dup} +
+

+ {dup.url} +

+
+ {#each dup.records as r} + {r.ysws || 'unknown'} + {/each} +
+
+ {/each} +
+
+ {/if} + + {#if dupResult.duplicatePlayableUrls.length > 0} +
+

+ duplicate playable URLs across YSWS ({dupResult.duplicatePlayableUrls.length}) +

+
+ {#each dupResult.duplicatePlayableUrls as dup} +
+

+ {dup.url} +

+
+ {#each dup.records as r} + {r.ysws || 'unknown'} + {/each} +
+
+ {/each} +
+
+ {/if} +
+ {/if} +
+