mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 16:28:20 +00:00
unified checks
This commit is contained in:
parent
5d6e7eab52
commit
ced2151b1d
3 changed files with 316 additions and 1 deletions
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>);
|
||||
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<void>((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<string, { id: string; ysws: string; codeUrl: string; playableUrl: string }[]>();
|
||||
const playableUrlCounts = new Map<string, { id: string; ysws: string; codeUrl: string; playableUrl: string }[]>();
|
||||
|
||||
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<string, string>);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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<UnifiedDuplicatesResult | null>(null);
|
||||
let dupError = $state<string | null>(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}
|
||||
</div>
|
||||
|
||||
<!-- Check Unified Airtable Duplicates -->
|
||||
<div class="mb-6 rounded-2xl border-4 border-black p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-lg font-bold">
|
||||
<SearchCheck size={20} />
|
||||
check unified airtable duplicates
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
find projects submitted to other YSWS programs with the same code or playable URL
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={checkUnifiedDuplicates}
|
||||
disabled={dupChecking}
|
||||
class="cursor-pointer rounded-full bg-black px-6 py-2 font-bold text-white transition-all hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{dupChecking ? 'checking...' : 'check now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if dupError}
|
||||
<div class="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">{dupError}</div>
|
||||
{/if}
|
||||
|
||||
{#if dupResult}
|
||||
<div class="mt-4 space-y-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
scanned {dupResult.totalRecords.toLocaleString()} unified airtable records
|
||||
</p>
|
||||
|
||||
{#if dupResult.nonScrapsMatches.length > 0}
|
||||
<div>
|
||||
<h4 class="mb-2 text-sm font-bold text-red-600 uppercase">
|
||||
scraps URLs found in other YSWS programs ({dupResult.nonScrapsMatches.length})
|
||||
</h4>
|
||||
<div class="max-h-96 space-y-2 overflow-y-auto">
|
||||
{#each dupResult.nonScrapsMatches as match}
|
||||
<div class="rounded-xl border-2 border-red-200 bg-red-50 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="rounded-full bg-red-600 px-2 py-0.5 text-xs font-bold text-white"
|
||||
>{match.ysws}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500">matched by: {match.matchType}</span>
|
||||
</div>
|
||||
{#if match.codeUrl}
|
||||
<p class="mt-1 truncate text-sm">
|
||||
<span class="font-bold">code:</span>
|
||||
<a
|
||||
href={match.codeUrl}
|
||||
target="_blank"
|
||||
class="text-blue-600 hover:underline">{match.codeUrl}</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{#if match.playableUrl}
|
||||
<p class="truncate text-sm">
|
||||
<span class="font-bold">playable:</span>
|
||||
<a
|
||||
href={match.playableUrl}
|
||||
target="_blank"
|
||||
class="text-blue-600 hover:underline">{match.playableUrl}</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg bg-green-50 p-4">
|
||||
<p class="font-bold text-green-700">
|
||||
no scraps URLs found in other YSWS programs
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dupResult.duplicateCodeUrls.length > 0}
|
||||
<div>
|
||||
<h4 class="mb-2 text-sm font-bold text-yellow-600 uppercase">
|
||||
duplicate code URLs across YSWS ({dupResult.duplicateCodeUrls.length})
|
||||
</h4>
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto">
|
||||
{#each dupResult.duplicateCodeUrls as dup}
|
||||
<div class="rounded-xl border-2 border-yellow-200 bg-yellow-50 p-3">
|
||||
<p class="truncate text-sm font-bold">
|
||||
<a
|
||||
href={dup.url}
|
||||
target="_blank"
|
||||
class="text-blue-600 hover:underline">{dup.url}</a
|
||||
>
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each dup.records as r}
|
||||
<span
|
||||
class="rounded-full border border-yellow-400 px-2 py-0.5 text-xs font-bold"
|
||||
>{r.ysws || 'unknown'}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dupResult.duplicatePlayableUrls.length > 0}
|
||||
<div>
|
||||
<h4 class="mb-2 text-sm font-bold text-yellow-600 uppercase">
|
||||
duplicate playable URLs across YSWS ({dupResult.duplicatePlayableUrls.length})
|
||||
</h4>
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto">
|
||||
{#each dupResult.duplicatePlayableUrls as dup}
|
||||
<div class="rounded-xl border-2 border-yellow-200 bg-yellow-50 p-3">
|
||||
<p class="truncate text-sm font-bold">
|
||||
<a
|
||||
href={dup.url}
|
||||
target="_blank"
|
||||
class="text-blue-600 hover:underline">{dup.url}</a
|
||||
>
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each dup.records as r}
|
||||
<span
|
||||
class="rounded-full border border-yellow-400 px-2 py-0.5 text-xs font-bold"
|
||||
>{r.ysws || 'unknown'}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Fix Negative Balances -->
|
||||
<div class="rounded-2xl border-4 border-black p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue