diff --git a/apps/web/app/(public)/leaderboard/page.tsx b/apps/web/app/(public)/leaderboard/page.tsx new file mode 100644 index 00000000..322334c1 --- /dev/null +++ b/apps/web/app/(public)/leaderboard/page.tsx @@ -0,0 +1,32 @@ +import LeaderboardClient from '@/components/leaderboard/leaderboard-client'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type SearchParams = Promise>; + +function normalizeWindow(v: unknown): '30d' | '365d' { + if (v === '30d') return '30d'; + if (v === '365d') return '365d'; + return '365d'; +} + +export default async function LeaderboardPage({ searchParams }: { searchParams?: SearchParams }) { + const sp = await searchParams; + const winParam = sp && 'window' in sp + ? Array.isArray(sp.window) ? sp.window[0] : sp.window + : undefined; + const initialWindow = normalizeWindow(winParam); + + return ( +
+
+

Global Leaderboard

+

+ Top contributors across GitHub and GitLab based on open source contributions. +

+
+ +
+ ); +} diff --git a/apps/web/app/api/internal/leaderboard/backfill/route.ts b/apps/web/app/api/internal/leaderboard/backfill/route.ts new file mode 100644 index 00000000..ec9d6486 --- /dev/null +++ b/apps/web/app/api/internal/leaderboard/backfill/route.ts @@ -0,0 +1,150 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; + +import { isCronAuthorized } from '@workspace/env/verify-cron'; +import { env } from '@workspace/env/server'; + +import { backfillLockKey, withLock, acquireLock, releaseLock } from '@workspace/api/redis/locks'; +import { setUserMetaFromProviders } from '@workspace/api/leaderboard/use-meta'; +import { refreshUserRollups } from '@workspace/api/leaderboard/aggregator'; +import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; +import { db } from '@workspace/db'; + +function ymd(d = new Date()) { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; +} + +const Body = z + .object({ + userId: z + .string() + .min(1) + .transform((s) => s.trim()), + githubLogin: z + .string() + .min(1) + .transform((s) => s.trim()) + .optional(), + gitlabUsername: z + .string() + .min(1) + .transform((s) => s.trim()) + .optional(), + + days: z.number().int().min(1).max(365).optional(), + concurrency: z.number().int().min(1).max(8).optional(), + }) + .refine((b) => !!b.githubLogin || !!b.gitlabUsername, { + message: 'At least one of githubLogin or gitlabUsername is required.', + }); + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization'); + if (!isCronAuthorized(auth)) return new Response('Unauthorized', { status: 401 }); + + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const body = parsed.data; + + const providers = ( + [ + ...(body.githubLogin ? (['github'] as const) : []), + ...(body.gitlabUsername ? (['gitlab'] as const) : []), + ] as Array<'github' | 'gitlab'> + ).sort(); + + const githubToken = env.GITHUB_TOKEN; + const gitlabToken = env.GITLAB_TOKEN; + const gitlabBaseUrl = 'https://gitlab.com'; + + if (providers.includes('github') && !githubToken) { + return new Response('Bad Request: github requested but GITHUB_TOKEN not set', { status: 400 }); + } + if (providers.includes('gitlab') && !gitlabToken) { + return new Response('Bad Request: gitlab requested but GITLAB_TOKEN not set', { status: 400 }); + } + + const ttlSec = 5 * 60; + const todayStr = ymd(new Date()); + + async function runOnce() { + const wrote = await refreshUserRollups( + { db }, + { + userId: body.userId, + githubLogin: body.githubLogin, + gitlabUsername: body.gitlabUsername, + githubToken, + gitlabToken, + gitlabBaseUrl, + }, + ); + + await syncUserLeaderboards(db, body.userId); + + await setUserMetaFromProviders(body.userId, body.githubLogin, body.gitlabUsername); + + return wrote; + } + + try { + if (providers.length === 2) { + const [p1, p2] = providers; + const k1 = backfillLockKey(p1 as 'github' | 'gitlab', body.userId); + const k2 = backfillLockKey(p2 as 'github' | 'gitlab', body.userId); + + return await withLock(k1, ttlSec, async () => { + const t2 = await acquireLock(k2, ttlSec); + if (!t2) throw new Error(`LOCK_CONFLICT:${p2}`); + try { + const out = await runOnce(); + return Response.json({ + ok: true, + userId: body.userId, + providers, + mode: 'rollups', + snapshotDate: todayStr, + wrote: out.wrote, + providerUsed: out.provider, + }); + } finally { + await releaseLock(k2, t2); + } + }); + } + + const p = providers[0]!; + const key = backfillLockKey(p, body.userId); + + return await withLock(key, ttlSec, async () => { + const out = await runOnce(); + return Response.json({ + ok: true, + userId: body.userId, + providers, + mode: 'rollups', + snapshotDate: todayStr, + wrote: out.wrote, + providerUsed: out.provider, + }); + }); + } catch (err: unknown) { + const id = Math.random().toString(36).slice(2, 8); + console.error(`[rollup-backfill:${id}]`, err); + const msg = String(err instanceof Error ? err.message : err); + if (msg.startsWith('LOCK_CONFLICT')) { + const p = msg.split(':')[1] || 'unknown'; + return new Response(`Conflict: backfill already running for ${p}`, { status: 409 }); + } + return new Response(`Internal Error (ref ${id})`, { status: 500 }); + } +} diff --git a/apps/web/app/api/internal/leaderboard/cron/daily/route.ts b/apps/web/app/api/internal/leaderboard/cron/daily/route.ts new file mode 100644 index 00000000..22378db1 --- /dev/null +++ b/apps/web/app/api/internal/leaderboard/cron/daily/route.ts @@ -0,0 +1,167 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { isCronAuthorized } from '@workspace/env/verify-cron'; +import { env } from '@workspace/env/server'; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; + +import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; +import { refreshUserRollups } from '@workspace/api/leaderboard/aggregator'; +import { redis } from '@workspace/api/redis/client'; +import { db } from '@workspace/db'; + +const USER_SET = 'lb:users'; +const META = (id: string) => `lb:user:${id}`; + +const Query = z.object({ + limit: z.coerce.number().int().min(1).max(5000).default(1000), + concurrency: z.coerce.number().int().min(1).max(8).default(4), + dry: z.union([z.literal('1'), z.literal('true'), z.literal('0'), z.literal('false')]).optional(), +}); + +function ymd(d = new Date()) { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; +} + +export async function GET(req: NextRequest) { + const ok = + isCronAuthorized(req.headers.get('authorization')) || !!req.headers.get('x-vercel-cron'); + if (!ok) return new Response('Unauthorized', { status: 401 }); + + const parsed = Query.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { limit, concurrency, dry } = parsed.data; + const isDry = dry === '1' || dry === 'true'; + + const snapshotDate = ymd(new Date()); + + try { + await redis.ping(); + + const allIdsRaw = await redis.smembers(USER_SET); + const userIds = (Array.isArray(allIdsRaw) ? allIdsRaw : []).map(String).slice(0, limit); + + if (userIds.length === 0) { + return Response.json({ + ok: true, + scanned: 0, + processed: 0, + skipped: 0, + errors: [], + snapshotDate, + note: `No user IDs in Redis set "${USER_SET}". Seed it via backfill.`, + }); + } + + const pipe = redis.pipeline(); + for (const id of userIds) pipe.hgetall(META(id)); + const rawResults = await pipe.exec(); + + const metaRows = rawResults.map((r) => { + const val = Array.isArray(r) ? r[1] : r; + return val && typeof val === 'object' ? (val as Record) : {}; + }); + + const asTrimmedString = (v: unknown): string | undefined => { + if (typeof v === 'string') return v.trim(); + if (v == null) return undefined; + if (typeof v === 'number' || typeof v === 'boolean') return String(v).trim(); + if (Array.isArray(v)) return v.length ? String(v[0]).trim() : undefined; + return undefined; + }; + + if (isDry) { + const preview = userIds.map((id, i) => { + const m = metaRows[i] || {}; + const githubLogin = asTrimmedString(m.githubLogin) ?? null; + const gitlabUsername = asTrimmedString(m.gitlabUsername) ?? null; + return { userId: id, githubLogin, gitlabUsername }; + }); + return Response.json({ + ok: true, + dryRun: true, + scanned: userIds.length, + sample: preview.slice(0, 10), + snapshotDate, + }); + } + + const workers = Math.max(1, Math.min(concurrency, 8)); + let idx = 0; + let processed = 0; + let skipped = 0; + const errors: Array<{ userId: string; error: string }> = []; + + const tasks = Array.from({ length: workers }, async () => { + while (true) { + const i = idx++; + if (i >= userIds.length) break; + + const userId = userIds[i]!; + const m = metaRows[i] || {}; + const githubLogin = asTrimmedString(m.githubLogin); + const gitlabUsername = asTrimmedString(m.gitlabUsername); + + if (!githubLogin && !gitlabUsername) { + skipped++; + continue; + } + + if (githubLogin && !env.GITHUB_TOKEN) { + errors.push({ userId, error: 'Missing GITHUB_TOKEN' }); + skipped++; + continue; + } + if (gitlabUsername && !env.GITLAB_TOKEN) { + errors.push({ userId, error: 'Missing GITLAB_TOKEN' }); + skipped++; + continue; + } + + try { + await refreshUserRollups( + { db }, + { + userId, + githubLogin, + gitlabUsername, + githubToken: env.GITHUB_TOKEN, + gitlabToken: env.GITLAB_TOKEN, + gitlabBaseUrl: env.GITLAB_ISSUER || 'https://gitlab.com', + }, + ); + + await syncUserLeaderboards(db, userId); + processed++; + } catch (err) { + errors.push({ userId, error: String(err instanceof Error ? err.message : err) }); + } + } + }); + + await Promise.all(tasks); + + return Response.json({ + ok: true, + scanned: userIds.length, + processed, + skipped, + errors, + snapshotDate, + }); + } catch (err: unknown) { + const msg = String(err instanceof Error ? `${err.name}: ${err.message}` : err); + if (env.VERCEL_ENV !== 'production') { + console.error('[cron/daily-rollups] fatal:', err); + return new Response(`Internal Error: ${msg}`, { status: 500 }); + } + return new Response('Internal Error', { status: 500 }); + } +} diff --git a/apps/web/app/api/leaderboard/details/route.ts b/apps/web/app/api/leaderboard/details/route.ts new file mode 100644 index 00000000..434fc5ae --- /dev/null +++ b/apps/web/app/api/leaderboard/details/route.ts @@ -0,0 +1,86 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { and, eq, inArray } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; + +import { contribRollups } from '@workspace/db/schema'; +import { db } from '@workspace/db'; + +const HAS_ALL_TIME = false as const; + +type WindowKey = '30d' | '365d' | 'all'; + +const Body = z.object({ + window: z.enum(HAS_ALL_TIME ? (['all', '30d', '365d'] as const) : (['30d', '365d'] as const)), + userIds: z.array(z.string().min(1)).max(2000), +}); + +const PERIOD_FROM_WINDOW: Record<'30d' | '365d', 'last_30d' | 'last_365d'> = { + '30d': 'last_30d', + '365d': 'last_365d', +}; + +export async function POST(req: NextRequest) { + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + + const { window, userIds } = parsed.data as { window: WindowKey; userIds: string[] }; + if (userIds.length === 0) { + return Response.json({ ok: true, window, entries: [] }); + } + + if (window === 'all' && !HAS_ALL_TIME) { + return new Response(`Bad Request: 'all' window not supported by current schema`, { + status: 400, + }); + } + + try { + const where = and( + inArray(contribRollups.userId, userIds), + window === 'all' + ? eq(contribRollups.period, 'all_time') + : eq(contribRollups.period, PERIOD_FROM_WINDOW[window]), + ); + + const rows = await db + .select({ + userId: contribRollups.userId, + commits: contribRollups.commits, + prs: contribRollups.prs, + issues: contribRollups.issues, + total: contribRollups.total, + }) + .from(contribRollups) + .where(where); + + const byId = new Map( + rows.map((r) => [ + r.userId, + { + commits: Number(r.commits ?? 0), + prs: Number(r.prs ?? 0), + issues: Number(r.issues ?? 0), + total: Number(r.total ?? 0), + }, + ]), + ); + + const entries = userIds.map((id) => { + const v = byId.get(id) ?? { commits: 0, prs: 0, issues: 0, total: 0 }; + return { userId: id, ...v }; + }); + + return Response.json({ ok: true, window, entries }); + } catch (err: unknown) { + const msg = String(err instanceof Error ? err.message : err); + console.error('[leaderboard/details]', err); + return new Response(`Internal Error: ${msg}`, { status: 500 }); + } +} diff --git a/apps/web/app/api/leaderboard/dev-backfill/route.ts b/apps/web/app/api/leaderboard/dev-backfill/route.ts new file mode 100644 index 00000000..7400644e --- /dev/null +++ b/apps/web/app/api/leaderboard/dev-backfill/route.ts @@ -0,0 +1,84 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +import { auth } from '@workspace/auth/server'; +import { env } from '@workspace/env/server'; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; + +const Body = z.object({ + users: z + .array( + z + .object({ + userId: z.string().min(1), + githubLogin: z.string().min(1).optional(), + gitlabUsername: z.string().min(1).optional(), + }) + .refine((u) => !!(u.githubLogin || u.gitlabUsername), { + message: 'At least one of githubLogin or gitlabUsername is required', + }), + ) + .min(1) + .max(200), + days: z.number().int().min(1).max(365).default(365), + concurrency: z.number().int().min(1).max(8).default(4), +}); + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: req.headers }).catch(() => null); + const role = session?.user?.role as string | undefined; + if (!session || !role || !['admin', 'moderator'].includes(role)) { + return new Response('Forbidden', { status: 403 }); + } + + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + + const { users, concurrency } = parsed.data; + const limit = Math.max(1, Math.min(concurrency, 8)); + const origin = env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` + : 'http://localhost:3000'; + + const url = new URL('/api/internal/leaderboard/backfill', origin).toString(); + + let i = 0, + ok = 0, + fail = 0; + const results: Array<{ userId: string; status: number }> = []; + + async function worker() { + while (true) { + const idx = i++; + if (idx >= users.length) return; + const u = users[idx]!; + try { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.CRON_SECRET}`, + }, + body: JSON.stringify({ + userId: u.userId, + githubLogin: u.githubLogin, + gitlabUsername: u.gitlabUsername, + }), + }); + results.push({ userId: u.userId, status: r.status }); + if (r.ok || r.status === 409) ok++; + else fail++; + } catch { + results.push({ userId: u.userId, status: 0 }); + fail++; + } + } + } + + await Promise.all(Array.from({ length: limit }, worker)); + return Response.json({ ok: fail === 0, summary: { ok, fail, total: users.length }, results }); +} diff --git a/apps/web/app/api/leaderboard/enroll/route.ts b/apps/web/app/api/leaderboard/enroll/route.ts new file mode 100644 index 00000000..3e6d72bb --- /dev/null +++ b/apps/web/app/api/leaderboard/enroll/route.ts @@ -0,0 +1,26 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { auth } from '@workspace/auth/server'; +import { NextRequest } from 'next/server'; +import { db } from '@workspace/db'; + +import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; +import { setUserMeta } from '@workspace/api/leaderboard/meta'; + +export async function POST(req: NextRequest) { + const sess = await auth.api.getSession({ headers: req.headers }).catch(() => null); + const user = sess?.user; + if (!user?.id) { + return new Response('Unauthorized', { status: 401 }); + } + + const guessGithub = + typeof user.username === 'string' && user.username ? user.username : undefined; + + await setUserMeta(user.id, { githubLogin: guessGithub }, { seedLeaderboards: false }); + await syncUserLeaderboards(db, user.id); + + return Response.json({ ok: true }); +} diff --git a/apps/web/app/api/leaderboard/export/route.ts b/apps/web/app/api/leaderboard/export/route.ts new file mode 100644 index 00000000..9a0049e4 --- /dev/null +++ b/apps/web/app/api/leaderboard/export/route.ts @@ -0,0 +1,77 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { NextRequest } from 'next/server'; +import { db } from '@workspace/db'; +import { z } from 'zod/v4'; + +import { getLeaderboardPage } from '@workspace/api/leaderboard/read'; +import { getUserMetas } from '@workspace/api/leaderboard/use-meta'; + +const Query = z.object({ + provider: z.enum(['combined', 'github', 'gitlab']).default('combined'), + window: z.enum(['30d', '365d']).default('30d'), + limit: z.coerce.number().int().min(1).max(2000).default(500), + cursor: z.coerce.number().int().min(0).default(0), +}); + +export async function GET(req: NextRequest) { + const parsed = Query.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { window, limit, cursor } = parsed.data; + + let entries: Array<{ userId: string; score: number }> = []; + let next = cursor; + + while (entries.length < limit) { + const page = await getLeaderboardPage(db, { + window, + limit: Math.min(200, limit - entries.length), + cursor: next, + }); + entries.push(...page.entries); + if (page.nextCursor == null) break; + next = page.nextCursor; + } + entries = entries.slice(0, limit); + + const userIds = entries.map((e) => e.userId); + const metas = await getUserMetas(userIds); + const metaMap = new Map(metas.map((m) => [m.userId, m])); + + const header = ['rank', 'userId', 'username', 'githubLogin', 'gitlabUsername', 'total']; + const lines = [header.join(',')]; + + entries.forEach((e, idx) => { + const rank = cursor + idx + 1; + const m = metaMap.get(e.userId); + + const row = [ + rank, + e.userId, + m?.username ?? '', + m?.githubLogin ?? '', + m?.gitlabUsername ?? '', + e.score, + ] + .map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v))) + .join(','); + + lines.push(row); + }); + + const csv = lines.join('\n'); + const filename = `leaderboard_${window}_${new Date().toISOString().slice(0, 10)}.csv`; + + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/apps/web/app/api/leaderboard/profiles/route.ts b/apps/web/app/api/leaderboard/profiles/route.ts new file mode 100644 index 00000000..4452294c --- /dev/null +++ b/apps/web/app/api/leaderboard/profiles/route.ts @@ -0,0 +1,22 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { getUserMetas } from '@workspace/api/leaderboard/use-meta'; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; + +const Body = z.object({ + userIds: z.array(z.string().min(1)).min(1).max(200), +}); + +export async function POST(req: NextRequest) { + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { userIds } = parsed.data; + const entries = await getUserMetas(userIds); + return Response.json({ ok: true, entries }); +} diff --git a/apps/web/app/api/leaderboard/route.ts b/apps/web/app/api/leaderboard/route.ts new file mode 100644 index 00000000..4bd487e7 --- /dev/null +++ b/apps/web/app/api/leaderboard/route.ts @@ -0,0 +1,46 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { db } from "@workspace/db"; +import { getLeaderboardPage } from "@workspace/api/leaderboard/read"; + +const HAS_ALL_TIME = false as const; + +const Query = z.object({ + window: z.enum(HAS_ALL_TIME ? (["all", "30d", "365d"] as const) : (["30d", "365d"] as const)) + .default("30d"), + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor: z.coerce.number().int().min(0).optional(), +}); + +export async function GET(req: NextRequest) { + const parsed = Query.safeParse(Object.fromEntries(req.nextUrl.searchParams.entries())); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const q = parsed.data; + + if (q.window === "all" && !HAS_ALL_TIME) { + return new Response(`Bad Request: 'all' window not supported by current schema`, { status: 400 }); + } + + const windowForReader = q.window === "all" ? ("365d" as "30d" | "365d") : q.window; + + const { entries, nextCursor, source } = await getLeaderboardPage(db, { + window: windowForReader, + limit: q.limit, + cursor: q.cursor, + }); + + return Response.json({ + ok: true, + window: q.window, + limit: q.limit, + cursor: q.cursor ?? 0, + nextCursor, + source, + entries, + }); +} diff --git a/apps/web/components/leaderboard/leaderboard-client.tsx b/apps/web/components/leaderboard/leaderboard-client.tsx new file mode 100644 index 00000000..dc872365 --- /dev/null +++ b/apps/web/components/leaderboard/leaderboard-client.tsx @@ -0,0 +1,401 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import * as React from 'react'; + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from '@workspace/ui/components/table'; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from '@workspace/ui/components/select'; +import { Card, CardContent } from '@workspace/ui/components/card'; +import { Button } from '@workspace/ui/components/button'; +import { Input } from '@workspace/ui/components/input'; + +type UIWindow = '30d' | '365d'; + +type TopEntry = { userId: string; score: number }; + +type DetailsEntry = { + userId: string; + commits?: number; + prs?: number; + issues?: number; + total?: number; +}; + +type Profile = { + userId: string; + username?: string; + avatarUrl?: string; + githubLogin?: string; + gitlabUsername?: string; +}; + +type LeaderRow = { + _profile?: Profile; + userId: string; + commits: number; + prs: number; + issues: number; + total: number; +}; + +type SortKey = 'rank' | 'userId' | 'total' | 'commits' | 'prs' | 'issues'; +type SortDir = 'asc' | 'desc'; + +async function fetchTop(window: UIWindow, limit: number, cursor = 0) { + const url = `/api/leaderboard?window=${window}&limit=${limit}&cursor=${cursor}`; + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) throw new Error(`Failed to fetch leaderboard: ${await res.text()}`); + return (await res.json()) as { + ok: boolean; + entries: TopEntry[]; + nextCursor: number | null; + source: 'redis' | 'db'; + }; +} + +async function fetchDetails(window: UIWindow, userIds: string[]) { + if (userIds.length === 0) return { ok: true, window, entries: [] as DetailsEntry[] }; + const res = await fetch(`/api/leaderboard/details`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ window, userIds }), + }); + if (!res.ok) throw new Error(`Failed to fetch details: ${await res.text()}`); + return (await res.json()) as { ok: true; window: UIWindow; entries: DetailsEntry[] }; +} + +async function fetchProfiles(userIds: string[]): Promise { + if (!userIds.length) return []; + const res = await fetch('/api/leaderboard/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ userIds }), + }); + if (!res.ok) { + const t = await res.text(); + throw new Error(`profiles ${res.status}: ${t.slice(0, 160)}${t.length > 160 ? '…' : ''}`); + } + const data = (await res.json()) as { ok: true; entries: Profile[] }; + return data.entries; +} + +export default function LeaderboardClient({ + initialWindow, +}: { + initialWindow: 'all' | '30d' | '365d'; +}) { + const router = useRouter(); + const search = useSearchParams(); + + const normalized: UIWindow = initialWindow === 'all' ? '365d' : initialWindow; + + const [window, setWindow] = React.useState(normalized); + const [rows, setRows] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [limit, setLimit] = React.useState(25); + const [cursor, setCursor] = React.useState(0); + const [nextCursor, setNextCursor] = React.useState(null); + + const [sortKey, setSortKey] = React.useState('rank'); + const [sortDir, setSortDir] = React.useState('asc'); + + const doFetch = React.useCallback(async (w: UIWindow, lim: number, cur: number) => { + setLoading(true); + setError(null); + try { + const top = await fetchTop(w, lim, cur); + const ids = top.entries.map((e) => e.userId); + const [details, profiles] = await Promise.all([fetchDetails(w, ids), fetchProfiles(ids)]); + const detailMap = new Map(details.entries.map((d) => [d.userId, d])); + const profileMap = new Map(profiles.map((p) => [p.userId, p])); + + const merged: LeaderRow[] = top.entries.map((e) => { + const d = detailMap.get(e.userId); + const commits = Number(d?.commits ?? 0); + const prs = Number(d?.prs ?? 0); + const issues = Number(d?.issues ?? 0); + const detailsTotal = Number(d?.total ?? commits + prs + issues); + const redisScore = Number(e.score ?? 0); + const total = Math.max(redisScore, detailsTotal); + + return { + userId: e.userId, + commits, + prs, + issues, + total, + _profile: profileMap.get(e.userId), + }; + }); + setRows(merged); + setNextCursor(top.nextCursor); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError(String(err)); + } + setRows([]); + setNextCursor(null); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + doFetch(window, limit, cursor); + }, [window, limit, cursor, doFetch]); + + React.useEffect(() => { + const params = new URLSearchParams(search?.toString() || ''); + params.set('window', window); + router.replace(`/leaderboard?${params.toString()}`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window]); + + function toggleSort(k: SortKey) { + if (sortKey === k) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + else { + setSortKey(k); + setSortDir(k === 'rank' ? 'asc' : 'desc'); + } + } + + const sortedRows = React.useMemo(() => { + if (!rows) return []; + const copy = [...rows]; + copy.sort((a, b) => { + let av: number | string = 0, + bv: number | string = 0; + switch (sortKey) { + case 'userId': + av = a.userId; + bv = b.userId; + break; + case 'total': + av = a.total; + bv = b.total; + break; + case 'commits': + av = a.commits; + bv = b.commits; + break; + case 'prs': + av = a.prs; + bv = b.prs; + break; + case 'issues': + av = a.issues; + bv = b.issues; + break; + case 'rank': + default: + av = 0; + bv = 0; + break; + } + if (typeof av === 'string' && typeof bv === 'string') { + return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); + } + return sortDir === 'asc' ? Number(av) - Number(bv) : Number(bv) - Number(av); + }); + return sortKey === 'rank' ? rows : copy; + }, [rows, sortKey, sortDir]); + + return ( +
+ + +
+ + +
+
+ + { + const n = Math.max(5, Math.min(100, Number(e.target.value || 25))); + setLimit(n); + setCursor(0); + }} + /> + +
+
+
+ + + +
+ + + + toggleSort('rank')}> + Rank + + toggleSort('userId')} + > + User + + toggleSort('total')} + > + Total + + toggleSort('commits')} + > + Commits + + toggleSort('prs')} + > + PRs + + toggleSort('issues')} + > + Issues + + + + + {loading && ( + + + Loading… + + + )} + {!loading && error && ( + + + {error} + + + )} + {!loading && !error && sortedRows.length === 0 && ( + + + No entries yet. + + + )} + {!loading && + !error && + sortedRows.map((r, idx) => ( + + {(cursor || 0) + idx + 1} + +
+
+ {r._profile?.avatarUrl && ( + + )} +
+
+
+ {r._profile?.username || r.userId} +
+
+ {r._profile?.githubLogin || r._profile?.gitlabUsername || '—'} +
+
+
+
+ {r.total} + {r.commits} + {r.prs} + {r.issues} +
+ ))} +
+
+
+
+
+ +
+ +
+ {rows?.length || 0} rows • {window.toUpperCase()} +
+ +
+ + + Export CSV + +
+ ); +} diff --git a/packages/api/package.json b/packages/api/package.json index bb113b24..3ad275d4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -5,7 +5,14 @@ "exports": { ".": "./src/root.ts", "./trpc": "./src/trpc.ts", - "./providers/types": "./src/providers/types.ts" + "./providers/types": "./src/providers/types.ts", + "./leaderboard/aggregator": "./src/leaderboard/aggregator.ts", + "./leaderboard/redis": "./src/leaderboard/redis.ts", + "./redis/client": "./src/redis/client.ts", + "./redis/locks": "./src/redis/lock.ts", + "./leaderboard/read": "./src/leaderboard/read.ts", + "./leaderboard/use-meta": "./src/leaderboard/use-meta.ts", + "./leaderboard/meta": "./src/leaderboard/meta.ts" }, "scripts": { "lint": "eslint ." diff --git a/packages/api/src/leaderboard/aggregator.ts b/packages/api/src/leaderboard/aggregator.ts new file mode 100644 index 00000000..87c3d267 --- /dev/null +++ b/packages/api/src/leaderboard/aggregator.ts @@ -0,0 +1,192 @@ +import type { DB } from '@workspace/db'; +import { sql } from 'drizzle-orm'; + +import { getGitlabContributionRollups } from '../providers/gitlab'; +import { getGithubContributionRollups } from '../providers/github'; +import { contribRollups } from '@workspace/db/schema'; + +export type AggregatorDeps = { db: DB }; + +export type RefreshUserRollupsArgs = { + userId: string; + + githubLogin?: string | null; + gitlabUsername?: string | null; + + githubToken?: string; + gitlabToken?: string; + gitlabBaseUrl?: string; +}; + +type PeriodKey = 'last_30d' | 'last_365d'; + +function nowUtc(): Date { + return new Date(); +} + +async function upsertRollup( + db: DB, + params: { + userId: string; + period: PeriodKey; + commits: number; + prs: number; + issues: number; + fetchedAt: Date; + }, +) { + const total = params.commits + params.prs + params.issues; + + await db + .insert(contribRollups) + .values({ + userId: params.userId, + period: params.period, + commits: params.commits, + prs: params.prs, + issues: params.issues, + total, + fetchedAt: params.fetchedAt, + updatedAt: sql`now()`, + }) + .onConflictDoUpdate({ + target: [contribRollups.userId, contribRollups.period], + set: { + commits: params.commits, + prs: params.prs, + issues: params.issues, + total, + fetchedAt: params.fetchedAt, + updatedAt: sql`now()`, + }, + }); +} + +export async function refreshUserRollups( + deps: AggregatorDeps, + args: RefreshUserRollupsArgs, +): Promise<{ + provider: 'github' | 'gitlab' | 'none'; + wrote: { + last_30d?: { commits: number; prs: number; issues: number; total: number }; + last_365d?: { commits: number; prs: number; issues: number; total: number }; + }; +}> { + const db = deps.db; + const now = nowUtc(); + + const hasGithub = !!args.githubLogin && !!args.githubToken; + const hasGitlab = !!args.gitlabUsername && !!args.gitlabToken; + + if (!hasGithub && !hasGitlab) { + return { provider: 'none', wrote: {} }; + } + + if (hasGithub && hasGitlab) { + // If this can never happen by design, you can throw instead. + // We'll prefer GitHub if both are accidentally present. + // throw new Error('User cannot have both GitHub and GitLab identities'); + } + + if (hasGithub) { + const roll = await getGithubContributionRollups(args.githubLogin!.trim(), args.githubToken!); + + await upsertRollup(db, { + userId: args.userId, + period: 'last_30d', + commits: roll.last30d.commits, + prs: roll.last30d.prs, + issues: roll.last30d.issues, + fetchedAt: now, + }); + + await upsertRollup(db, { + userId: args.userId, + period: 'last_365d', + commits: roll.last365d.commits, + prs: roll.last365d.prs, + issues: roll.last365d.issues, + fetchedAt: now, + }); + + return { + provider: 'github', + wrote: { + last_30d: { + commits: roll.last30d.commits, + prs: roll.last30d.prs, + issues: roll.last30d.issues, + total: roll.last30d.commits + roll.last30d.prs + roll.last30d.issues, + }, + last_365d: { + commits: roll.last365d.commits, + prs: roll.last365d.prs, + issues: roll.last365d.issues, + total: roll.last365d.commits + roll.last365d.prs + roll.last365d.issues, + }, + }, + }; + } + + const base = (args.gitlabBaseUrl?.trim() || 'https://gitlab.com') as string; + const r = await getGitlabContributionRollups(args.gitlabUsername!.trim(), base, args.gitlabToken); + + await upsertRollup(db, { + userId: args.userId, + period: 'last_30d', + commits: r.last30d.commits, + prs: r.last30d.prs, + issues: r.last30d.issues, + fetchedAt: now, + }); + + await upsertRollup(db, { + userId: args.userId, + period: 'last_365d', + commits: r.last365d.commits, + prs: r.last365d.prs, + issues: r.last365d.issues, + fetchedAt: now, + }); + + return { + provider: 'gitlab', + wrote: { + last_30d: { + commits: r.last30d.commits, + prs: r.last30d.prs, + issues: r.last30d.issues, + total: r.last30d.commits + r.last30d.prs + r.last30d.issues, + }, + last_365d: { + commits: r.last365d.commits, + prs: r.last365d.prs, + issues: r.last365d.issues, + total: r.last365d.commits + r.last365d.prs + r.last365d.issues, + }, + }, + }; +} + +export async function refreshManyUsersRollups< + T extends RefreshUserRollupsArgs & { userId: string }, +>( + deps: AggregatorDeps, + users: readonly T[], + opts?: { concurrency?: number; onProgress?: (done: number, total: number) => void }, +) { + const limit = Math.max(1, opts?.concurrency ?? 4); + const total = users.length; + let idx = 0; + + const worker = async () => { + while (true) { + const i = idx++; + if (i >= total) return; + await refreshUserRollups(deps, users[i]!); + opts?.onProgress?.(i + 1, total); + } + }; + + await Promise.all(Array.from({ length: limit }, worker)); +} diff --git a/packages/api/src/leaderboard/meta.ts b/packages/api/src/leaderboard/meta.ts new file mode 100644 index 00000000..c0468191 --- /dev/null +++ b/packages/api/src/leaderboard/meta.ts @@ -0,0 +1,39 @@ +import { syncUserLeaderboards } from './redis'; +import { redis } from '../redis/client'; + +export type UserMetaInput = { + username?: string | null; + avatar?: string | null; + githubLogin?: string | null; + gitlabUsername?: string | null; +}; + +export async function setUserMeta( + userId: string, + meta: UserMetaInput, + opts: { seedLeaderboards?: boolean } = { seedLeaderboards: true }, +): Promise { + const updates: Record = {}; + const put = (k: string, v?: string | null) => { + if (v && v.trim()) updates[k] = v.trim(); + }; + + put('username', meta.username); + put('avatar', meta.avatar); + put('image', meta.avatar); + put('avatarUrl', meta.avatar); + put('imageUrl', meta.avatar); + + put('githubLogin', meta.githubLogin); + put('gitlabUsername', meta.gitlabUsername); + + if (Object.keys(updates).length > 0) { + await redis.hset(`lb:user:${userId}`, updates); + } + await redis.sadd('lb:users', userId); + + if (opts.seedLeaderboards) { + const { db } = await import('@workspace/db'); + await syncUserLeaderboards(db, userId); + } +} diff --git a/packages/api/src/leaderboard/read.ts b/packages/api/src/leaderboard/read.ts new file mode 100644 index 00000000..9f5ccc3e --- /dev/null +++ b/packages/api/src/leaderboard/read.ts @@ -0,0 +1,103 @@ +import { redis } from '../redis/client'; +import type { DB } from '@workspace/db'; +import { desc, eq } from 'drizzle-orm'; + +import { contribRollups } from '@workspace/db/schema'; + +export type WindowKey = '30d' | '365d'; + +type PeriodKey = 'last_30d' | 'last_365d'; + +const PERIOD_FROM_WINDOW: Record = { + '30d': 'last_30d', + '365d': 'last_365d', +} as const; + +const REDIS_KEYS: Record = { + '30d': 'lb:rollups:30d', + '365d': 'lb:rollups:365d', +} as const; + +export type LeaderRow = { userId: string; score: number }; + +type ZRangeItemObj = { member?: unknown; score?: unknown }; + +function parseZRange(res: unknown): LeaderRow[] { + if (!res) return []; + + if (Array.isArray(res) && res.length > 0 && typeof res[0] === 'object' && res[0] !== null) { + return (res as ZRangeItemObj[]).flatMap((x) => { + const id = typeof x.member === 'string' ? x.member : String(x.member ?? ''); + const n = Number(x.score ?? 0); + return id ? [{ userId: id, score: Number.isFinite(n) ? n : 0 }] : []; + }); + } + + if (Array.isArray(res)) { + const out: LeaderRow[] = []; + for (let i = 0; i < res.length; i += 2) { + const id = String(res[i] ?? ''); + const n = Number(res[i + 1] ?? 0); + if (id) out.push({ userId: id, score: Number.isFinite(n) ? n : 0 }); + } + return out; + } + + return []; +} + +async function topFromRedis(window: WindowKey, start: number, stop: number): Promise { + try { + const key = REDIS_KEYS[window]; + const res = await redis.zrange(key, start, stop, { rev: true, withScores: true }); + return parseZRange(res); + } catch (err) { + console.error('Redis error in topFromRedis:', err); + return []; + } +} + +async function topFromDb( + db: DB, + window: WindowKey, + limit: number, + offset: number, +): Promise { + const period = PERIOD_FROM_WINDOW[window]; + + const rows = await db + .select({ + userId: contribRollups.userId, + score: contribRollups.total, + }) + .from(contribRollups) + .where(eq(contribRollups.period, period)) + .orderBy(desc(contribRollups.total)) + .limit(limit) + .offset(offset); + + return rows.map((r) => ({ userId: r.userId, score: Number(r.score ?? 0) })); +} + +export async function getLeaderboardPage( + db: DB, + opts: { + window: WindowKey; + limit: number; + cursor?: number; + }, +): Promise<{ entries: LeaderRow[]; nextCursor: number | null; source: 'redis' | 'db' }> { + const limit = Math.min(Math.max(opts.limit, 1), 100); + const start = Math.max(opts.cursor ?? 0, 0); + const stop = start + limit - 1; + + const fromRedis = await topFromRedis(opts.window, start, stop); + if (fromRedis.length > 0) { + const nextCursor = fromRedis.length === limit ? start + limit : null; + return { entries: fromRedis, nextCursor, source: 'redis' }; + } + + const fromDb = await topFromDb(db, opts.window, limit, start); + const nextCursor = fromDb.length === limit ? start + limit : null; + return { entries: fromDb, nextCursor, source: 'db' }; +} diff --git a/packages/api/src/leaderboard/redis.ts b/packages/api/src/leaderboard/redis.ts new file mode 100644 index 00000000..bbd0278d --- /dev/null +++ b/packages/api/src/leaderboard/redis.ts @@ -0,0 +1,76 @@ +import { contribRollups } from '@workspace/db/schema'; +import { redis } from '../redis/client'; +import type { DB } from '@workspace/db'; +import { eq } from 'drizzle-orm'; + +type PeriodKey = 'last_30d' | 'last_365d'; + +const PERIOD_KEYS: Record = { + last_30d: 'lb:rollups:30d', + last_365d: 'lb:rollups:365d', +}; + +const USER_SET = 'lb:users'; + +export async function syncUserLeaderboards(db: DB, userId: string): Promise { + await redis.sadd(USER_SET, userId); + + const rows = await db + .select({ + period: contribRollups.period, + total: contribRollups.total, + }) + .from(contribRollups) + .where(eq(contribRollups.userId, userId)); + + const pipe = redis.pipeline(); + + for (const r of rows) { + const period = r.period as PeriodKey; + const key = PERIOD_KEYS[period]; + if (!key) continue; + + const total = Number(r.total ?? 0); + pipe.zadd(key, { score: total, member: userId }); + } + + pipe.sadd(USER_SET, userId); + await pipe.exec(); +} + +export async function removeUserFromLeaderboards(userId: string): Promise { + const keys = Object.values(PERIOD_KEYS); + const pipe = redis.pipeline(); + for (const k of keys) pipe.zrem(k, userId); + pipe.srem(USER_SET, userId); + await pipe.exec(); +} + +export async function topPeriod(limit = 10, period: PeriodKey = 'last_30d') { + const key = PERIOD_KEYS[period]; + const res = await redis.zrange(key, 0, limit - 1, { rev: true, withScores: true }); + + if (Array.isArray(res) && res.length && typeof res[0] === 'object') { + return (res as Array<{ member: string; score: number | string }>).map(({ member, score }) => ({ + userId: member, + score: typeof score === 'string' ? Number(score) : Number(score ?? 0), + })); + } + + if (Array.isArray(res)) { + const out: Array<{ userId: string; score: number }> = []; + for (let i = 0; i < res.length; i += 2) { + const member = String(res[i] ?? ''); + const score = Number(res[i + 1] ?? 0); + out.push({ userId: member, score }); + } + return out; + } + + return []; +} + +export async function allKnownUserIds(): Promise { + const ids = await redis.smembers(USER_SET); + return Array.isArray(ids) ? ids.map(String) : []; +} diff --git a/packages/api/src/leaderboard/use-meta.ts b/packages/api/src/leaderboard/use-meta.ts new file mode 100644 index 00000000..206dade7 --- /dev/null +++ b/packages/api/src/leaderboard/use-meta.ts @@ -0,0 +1,66 @@ +import { redis } from '../redis/client'; + +export type UserMeta = { + userId: string; + username?: string; + avatarUrl?: string; + githubLogin?: string; + gitlabUsername?: string; +}; + +const metaKey = (userId: string) => `lb:user:${userId}`; + +export async function setUserMetaFromProviders( + userId: string, + githubLogin?: string | null, + gitlabUsername?: string | null, +): Promise { + const updates: Record = {}; + const existing = (await redis.hgetall(metaKey(userId)).catch(() => ({}))) as Record< + string, + string | undefined + >; + + if (githubLogin && githubLogin.trim()) { + const gh = githubLogin.trim(); + updates.githubLogin = gh; + if (!existing.username && !updates.username) updates.username = gh; + if (!existing.avatarUrl && !updates.avatarUrl) + updates.avatarUrl = `https://github.com/${gh}.png?size=80`; + } + if (gitlabUsername && gitlabUsername.trim()) { + const gl = gitlabUsername.trim(); + updates.gitlabUsername = gl; + if (!existing.username && !updates.username) updates.username = gl; + if (!existing.avatarUrl && !updates.avatarUrl) + updates.avatarUrl = `https://gitlab.com/${gl}.png?width=80`; + } + + if (Object.keys(updates).length > 0) { + await redis.hset(metaKey(userId), updates); + } +} + +export async function getUserMetas(userIds: string[]): Promise { + const pipe = redis.pipeline(); + for (const id of userIds) pipe.hgetall(metaKey(id)); + const rows = await pipe.exec(); + + return rows.map((raw, i) => { + const id = userIds[i]!; + const m = (raw || {}) as Record; + const username = m.username || m.githubLogin || m.gitlabUsername || id.slice(0, 8); + const avatarUrl = + m.avatarUrl || + (m.githubLogin ? `https://github.com/${m.githubLogin}.png?size=80` : undefined) || + (m.gitlabUsername ? `https://gitlab.com/${m.gitlabUsername}.png?width=80` : undefined); + + return { + userId: id, + username, + avatarUrl, + githubLogin: m.githubLogin, + gitlabUsername: m.gitlabUsername, + }; + }); +} diff --git a/packages/api/src/providers/github.ts b/packages/api/src/providers/github.ts new file mode 100644 index 00000000..08a18e56 --- /dev/null +++ b/packages/api/src/providers/github.ts @@ -0,0 +1,217 @@ +const GITHUB_GQL_ENDPOINT = 'https://api.github.com/graphql'; + +import { z } from 'zod/v4'; + +export type DateLike = string | Date; +export type DateRange = { from: DateLike; to: DateLike }; + +export type GithubContributionTotals = { + login: string; + commits: number; + prs: number; + issues: number; + rateLimit?: { + cost: number; + remaining: number; + resetAt: string; + }; +}; + +const RateLimitSchema = z + .object({ + cost: z.number(), + remaining: z.number(), + resetAt: z.string(), + }) + .optional(); + +const CalendarSchema = z.object({ + totalContributions: z.number(), +}); + +const WindowSchema = z.object({ + contributionCalendar: CalendarSchema, + totalCommitContributions: z.number(), + totalPullRequestContributions: z.number(), + totalIssueContributions: z.number(), +}); + +const UserWindowsSchema = z.object({ + id: z.string(), + login: z.string(), + c30: WindowSchema.optional(), + c365: WindowSchema.optional(), + cwin: WindowSchema.optional(), +}); + +const GraphQLDataSchema = z.object({ + user: UserWindowsSchema.nullable(), + rateLimit: RateLimitSchema, +}); + +const GraphQLResponseSchema = z.object({ + data: GraphQLDataSchema.optional(), + errors: z + .array( + z.object({ + message: z.string(), + type: z.string().optional(), + path: z.array(z.union([z.string(), z.number()])).optional(), + }), + ) + .optional(), +}); + +function toIsoDateTime(x: DateLike): string { + const d = typeof x === 'string' ? new Date(x) : x; + return d.toISOString(); +} + +async function githubGraphQLRequest({ + token, + query, + variables, +}: { + token: string; + query: string; + variables: Record; +}): Promise { + if (!token) throw new Error('GitHub GraphQL token is required. Pass GITHUB_TOKEN.'); + + const res = await fetch(GITHUB_GQL_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`GitHub GraphQL HTTP ${res.status}: ${text || res.statusText}`); + } + + const json = (await res.json()) as unknown; + const parsed = GraphQLResponseSchema.safeParse(json); + if (!parsed.success) throw new Error('Unexpected GitHub GraphQL response shape'); + if (parsed.data.errors?.length) { + const msgs = parsed.data.errors.map((e) => e.message).join('; '); + throw new Error(`GitHub GraphQL error(s): ${msgs}`); + } + + const data = parsed.data.data; + if (!data) throw new Error('GitHub GraphQL returned no data'); + return data as T; +} + +export async function getGithubContributionTotals( + login: string, + range: DateRange, + token: string, +): Promise { + const query = ` + query ($login: String!, $from: DateTime!, $to: DateTime!) { + user(login: $login) { + id + login + cwin: contributionsCollection(from: $from, to: $to) { + contributionCalendar { totalContributions } + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + } + rateLimit { cost remaining resetAt } + } + `; + + const data = await githubGraphQLRequest>({ + token, + query, + variables: { login, from: toIsoDateTime(range.from), to: toIsoDateTime(range.to) }, + }); + + const w = data.user?.cwin; + return { + login: data.user?.login ?? login, + commits: w?.totalCommitContributions ?? 0, + prs: w?.totalPullRequestContributions ?? 0, + issues: w?.totalIssueContributions ?? 0, + rateLimit: data.rateLimit ? { ...data.rateLimit } : undefined, + }; +} + +export async function getGithubContributionRollups( + login: string, + token: string, +): Promise<{ + login: string; + last30d: GithubContributionTotals; + last365d: GithubContributionTotals; + rateLimit?: GithubContributionTotals['rateLimit']; +}> { + const now = new Date(); + const to = now.toISOString(); + const from30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const from365 = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(); + + const query = ` + query ($login: String!, $from30: DateTime!, $from365: DateTime!, $to: DateTime!) { + user(login: $login) { + id + login + c30: contributionsCollection(from: $from30, to: $to) { + contributionCalendar { totalContributions } + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + c365: contributionsCollection(from: $from365, to: $to) { + contributionCalendar { totalContributions } + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + } + rateLimit { cost remaining resetAt } + } + `; + + const data = await githubGraphQLRequest>({ + token, + query, + variables: { login, from30, from365, to }, + }); + + const rl = data.rateLimit ? { ...data.rateLimit } : undefined; + const userLogin = data.user?.login ?? login; + + const pick = (w?: z.infer): GithubContributionTotals => ({ + login: userLogin, + commits: w?.totalCommitContributions ?? 0, + prs: w?.totalPullRequestContributions ?? 0, + issues: w?.totalIssueContributions ?? 0, + rateLimit: rl, + }); + + return { + login: userLogin, + last30d: pick(data.user?.c30), + last365d: pick(data.user?.c365), + rateLimit: rl, + }; +} + +export async function getGithubContributionTotalsForDay( + login: string, + dayUtc: DateLike, + token: string, +): Promise { + const d = typeof dayUtc === 'string' ? new Date(dayUtc) : dayUtc; + const from = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); + const to = new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 0, 0, 0, 0), + ); + return getGithubContributionTotals(login, { from, to }, token); +} diff --git a/packages/api/src/providers/gitlab.ts b/packages/api/src/providers/gitlab.ts new file mode 100644 index 00000000..b4dcd2a9 --- /dev/null +++ b/packages/api/src/providers/gitlab.ts @@ -0,0 +1,427 @@ +import { z } from 'zod/v4'; + +export type DateLike = string | Date; +export type DateRange = { from: DateLike; to: DateLike }; + +export type GitlabContributionTotals = { + username: string; + commits: number; + prs: number; + issues: number; + meta?: { + pagesFetched: number; + perPage: number; + publicEventsCount?: number; + totalEventsScanned?: number; + }; +}; + +const GitlabUserSchema = z.object({ + id: z.number(), + username: z.string(), + name: z.string().optional(), +}); + +const GitlabEventSchema = z.object({ + id: z.number(), + project_id: z.number().optional(), + action_name: z.string().optional(), + target_type: z.string().nullable().optional(), + created_at: z.string(), + push_data: z + .object({ + commit_count: z.number().optional(), + }) + .optional(), +}); + +function cleanBaseUrl(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +function toIso8601(input: DateLike): string { + const d = input instanceof Date ? input : new Date(input); + if (Number.isNaN(d.getTime())) { + throw new Error(`Invalid DateLike: ${String(input)}`); + } + return d.toISOString(); +} + +function startOfUtcDay(d: DateLike): Date { + const date = d instanceof Date ? new Date(d) : new Date(d); + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0), + ); +} + +function addDaysUTC(d: Date, days: number): Date { + const copy = new Date(d); + copy.setUTCDate(copy.getUTCDate() + days); + return copy; +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function glGet( + baseUrl: string, + path: string, + token?: string, + query?: Record, +): Promise { + const u = new URL(cleanBaseUrl(baseUrl) + path); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null) u.searchParams.set(k, String(v)); + } + } + + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) { + headers['PRIVATE-TOKEN'] = token; + headers['Authorization'] = `Bearer ${token}`; + } + + const controller = new AbortController(); + const to = setTimeout(() => controller.abort(), 15_000); + + try { + let res = await fetch(u.toString(), { headers, signal: controller.signal }); + + if (res.status === 429) { + const retryAfter = Number(res.headers.get('Retry-After') || 1); + const waitSec = Math.min(Math.max(retryAfter, 1), 10); + await sleep(waitSec * 1000); + res = await fetch(u.toString(), { headers, signal: controller.signal }); + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`GitLab HTTP ${res.status}: ${text || res.statusText} (${u})`); + } + + return res; + } finally { + clearTimeout(to); + } +} + +export async function resolveGitlabUserId( + username: string, + baseUrl: string, + token?: string, +): Promise<{ id: number; username: string } | null> { + const res = await glGet(baseUrl, `/api/v4/users`, token, { username, per_page: 1 }); + const json = await res.json(); + const arr = z.array(GitlabUserSchema).parse(json); + if (!arr.length) return null; + return { id: arr[0]!.id, username: arr[0]!.username }; +} + +type FetchEventsOptions = { + afterIso: string; + beforeIso: string; + perPage?: number; + maxPages?: number; +}; + +const projectVisibilityCache = new Map(); + +async function getProjectVisibility( + baseUrl: string, + projectId: number, + token?: string, +): Promise<'public' | 'private' | 'internal' | 'unknown'> { + const cached = projectVisibilityCache.get(projectId); + if (cached) return cached; + + try { + const res = await glGet(baseUrl, `/api/v4/projects/${projectId}`, token); + const data = (await res.json()) as { visibility?: string }; + const vis = (data?.visibility ?? 'unknown') as 'public' | 'private' | 'internal' | 'unknown'; + projectVisibilityCache.set(projectId, vis); + return vis; + } catch { + projectVisibilityCache.set(projectId, 'unknown'); + return 'unknown'; + } +} + +async function fetchUserEventsByWindow( + userId: number, + baseUrl: string, + token: string | undefined, + opts: FetchEventsOptions, +): Promise<{ + events: z.infer[]; + pagesFetched: number; + perPage: number; + totalScanned: number; +}> { + const perPage = Math.min(Math.max(opts.perPage ?? 100, 20), 100); + const maxPages = Math.min(Math.max(opts.maxPages ?? 10, 1), 50); + const lowerMs = new Date(opts.afterIso).getTime(); + const upperMs = new Date(opts.beforeIso).getTime(); + + let page = 1; + let pagesFetched = 0; + let totalScanned = 0; + const out: z.infer[] = []; + + while (true) { + const res = await glGet(baseUrl, `/api/v4/users/${userId}/events`, token, { + after: opts.afterIso, + before: opts.beforeIso, + per_page: perPage, + page, + scope: 'all', + }); + pagesFetched++; + + const json = await res.json(); + const events = z.array(GitlabEventSchema).parse(json); + totalScanned += events.length; + + const filteredByWindow = events.filter((e) => { + const t = new Date(e.created_at).getTime(); + return t >= lowerMs && t < upperMs; + }); + out.push(...filteredByWindow); + + if ( + filteredByWindow.length === 0 && + events.length > 0 && + Math.max(...events.map((e) => new Date(e.created_at).getTime())) < lowerMs + ) { + break; + } + + const nextPageHeader = res.headers.get('X-Next-Page'); + const hasNext = !!nextPageHeader && nextPageHeader !== '0'; + if (!hasNext) break; + + const next = Number(nextPageHeader); + if (!Number.isFinite(next) || next <= 0) break; + if (next > maxPages) break; + + page = next; + } + + return { events: out, pagesFetched, perPage, totalScanned }; +} + +async function filterPublicEvents( + baseUrl: string, + token: string | undefined, + events: z.infer[], +): Promise[]> { + const byProject = new Map[]>(); + const orphan: z.infer[] = []; + + for (const e of events) { + if (typeof e.project_id === 'number') { + const arr = byProject.get(e.project_id) ?? []; + arr.push(e); + byProject.set(e.project_id, arr); + } else { + orphan.push(e); + } + } + + const out: z.infer[] = []; + for (const [pid, list] of byProject) { + const vis = await getProjectVisibility(baseUrl, pid, token); + if (vis === 'public') out.push(...list); + } + + return out; +} + +function reducePublicContributionCounts(events: z.infer[]) { + let commits = 0; + let prs = 0; + let issues = 0; + + for (const e of events) { + const target = e.target_type ?? undefined; + const action = (e.action_name || '').toLowerCase(); + + if (e.push_data && typeof e.push_data.commit_count === 'number') { + if (action.includes('push')) { + commits += Math.max(0, e.push_data.commit_count || 0); + continue; + } + } + + if (target === 'MergeRequest' && action === 'opened') { + prs += 1; + continue; + } + + if (target === 'Issue' && action === 'opened') { + issues += 1; + continue; + } + } + + return { commits, prs, issues }; +} + +export async function getGitlabContributionTotals( + username: string, + range: DateRange, + baseUrl: string, + token?: string, +): Promise { + projectVisibilityCache.clear(); + + const fromIso = toIso8601(range.from); + const toIso = toIso8601(range.to); + + const user = await resolveGitlabUserId(username, baseUrl, token); + if (!user) { + return { username, commits: 0, prs: 0, issues: 0 }; + } + + const { events, pagesFetched, perPage, totalScanned } = await fetchUserEventsByWindow( + user.id, + baseUrl, + token, + { + afterIso: fromIso, + beforeIso: toIso, + perPage: 100, + maxPages: 25, + }, + ); + + const publicEvents = await filterPublicEvents(baseUrl, token, events); + const totals = reducePublicContributionCounts(publicEvents); + + return { + username: user.username, + ...totals, + meta: { + pagesFetched, + perPage, + publicEventsCount: publicEvents.length, + totalEventsScanned: totalScanned, + }, + }; +} + +export async function getGitlabContributionRollups( + username: string, + baseUrl: string, + token?: string, +): Promise<{ + username: string; + last30d: GitlabContributionTotals; + last365d: GitlabContributionTotals; + meta: { + pagesFetched: number; + perPage: number; + publicEventsCount365: number; + publicEventsCount30: number; + totalEventsScanned: number; + windowFrom365: string; + windowTo: string; + }; +}> { + projectVisibilityCache.clear(); + + const now = new Date(); + const toIso = now.toISOString(); + const from365Iso = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const from30Iso = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + const user = await resolveGitlabUserId(username, baseUrl, token); + if (!user) { + const empty: GitlabContributionTotals = { username, commits: 0, prs: 0, issues: 0 }; + return { + username, + last30d: empty, + last365d: empty, + meta: { + pagesFetched: 0, + perPage: 0, + publicEventsCount365: 0, + publicEventsCount30: 0, + totalEventsScanned: 0, + windowFrom365: from365Iso, + windowTo: toIso, + }, + }; + } + + const { events, pagesFetched, perPage, totalScanned } = await fetchUserEventsByWindow( + user.id, + baseUrl, + token, + { + afterIso: from365Iso, + beforeIso: toIso, + perPage: 100, + maxPages: 25, + }, + ); + + const publicEvents365 = await filterPublicEvents(baseUrl, token, events); + + const from30Ms = new Date(from30Iso).getTime(); + const publicEvents30 = publicEvents365.filter( + (e) => new Date(e.created_at).getTime() >= from30Ms, + ); + + const totals365 = reducePublicContributionCounts(publicEvents365); + const totals30 = reducePublicContributionCounts(publicEvents30); + + const last365d: GitlabContributionTotals = { + username: user.username, + ...totals365, + meta: { + pagesFetched, + perPage, + publicEventsCount: publicEvents365.length, + totalEventsScanned: totalScanned, + }, + }; + + const last30d: GitlabContributionTotals = { + username: user.username, + ...totals30, + meta: { + pagesFetched, + perPage, + publicEventsCount: publicEvents30.length, + totalEventsScanned: totalScanned, + }, + }; + + return { + username: user.username, + last30d, + last365d, + meta: { + pagesFetched, + perPage, + publicEventsCount365: publicEvents365.length, + publicEventsCount30: publicEvents30.length, + totalEventsScanned: totalScanned, + windowFrom365: from365Iso, + windowTo: toIso, + }, + }; +} + +export async function getGitlabContributionTotalsForDay( + username: string, + dayUtc: DateLike, + baseUrl: string, + token?: string, +): Promise { + const start = startOfUtcDay(dayUtc); + const end = addDaysUTC(start, 1); + return getGitlabContributionTotals(username, { from: start, to: end }, baseUrl, token); +} diff --git a/packages/api/src/redis/client.ts b/packages/api/src/redis/client.ts new file mode 100644 index 00000000..4c56ef90 --- /dev/null +++ b/packages/api/src/redis/client.ts @@ -0,0 +1,7 @@ +import { Redis } from "@upstash/redis"; +import { env } from "@workspace/env/server"; + +export const redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, +}); diff --git a/packages/api/src/redis/lock.ts b/packages/api/src/redis/lock.ts new file mode 100644 index 00000000..82543a11 --- /dev/null +++ b/packages/api/src/redis/lock.ts @@ -0,0 +1,37 @@ +import { redis } from './client'; + +export async function acquireLock(key: string, ttlSec = 60): Promise { + const token = Math.random().toString(36).slice(2); + const ok = await redis.set(key, token, { nx: true, ex: ttlSec }); + return ok === 'OK' ? token : null; +} + +export async function releaseLock(key: string, token: string): Promise { + try { + await redis.eval( + 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end', + [key], + [token], + ); + } catch { + //ignore + } +} + +export async function withLock(key: string, ttlSec: number, fn: () => Promise): Promise { + const token = await acquireLock(key, ttlSec); + if (!token) throw new Error('LOCK_CONFLICT'); + try { + return await fn(); + } finally { + await releaseLock(key, token); + } +} + +export function dailyLockKey(provider: 'github' | 'gitlab', userId: string, yyyymmdd: string) { + return `lock:daily:${provider}:${userId}:${yyyymmdd}`; +} + +export function backfillLockKey(provider: 'github' | 'gitlab', userId: string) { + return `lock:backfill:${provider}:${userId}`; +} diff --git a/packages/api/src/routers/profile.ts b/packages/api/src/routers/profile.ts index a1c8c0a6..54191b73 100644 --- a/packages/api/src/routers/profile.ts +++ b/packages/api/src/routers/profile.ts @@ -30,9 +30,9 @@ type ActivityItem = { }; export const profileRouter = createTRPCRouter({ - getProfile: publicProcedure.input(z.object({ + getProfile: publicProcedure.input(z.object({ id: z.string(), - provider: z.enum(['github', 'gitlab']).optional() + provider: z.enum(['github', 'gitlab']).optional() })).query(async ({ ctx, input }) => { const targetId = input.id; @@ -49,7 +49,7 @@ export const profileRouter = createTRPCRouter({ // If provider is specified, look for that specific account // Otherwise, get the first available account (backward compatibility) - const userAccount = input.provider + const userAccount = input.provider ? await ctx.db.query.account.findFirst({ where: and(eq(account.userId, targetId), eq(account.providerId, input.provider)), }) diff --git a/packages/api/src/utils/cache.ts b/packages/api/src/utils/cache.ts index f5c396f3..21837e72 100644 --- a/packages/api/src/utils/cache.ts +++ b/packages/api/src/utils/cache.ts @@ -1,10 +1,4 @@ -import { env } from '@workspace/env/server'; -import { Redis } from '@upstash/redis'; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); +import { redis } from "../redis/client"; interface CacheOptions { ttl?: number; diff --git a/packages/api/src/utils/rate-limit.ts b/packages/api/src/utils/rate-limit.ts index 3f3aa9bd..b402e1c5 100644 --- a/packages/api/src/utils/rate-limit.ts +++ b/packages/api/src/utils/rate-limit.ts @@ -1,11 +1,5 @@ import { Ratelimit } from '@upstash/ratelimit'; -import { env } from '@workspace/env/server'; -import { Redis } from '@upstash/redis'; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); +import { redis } from "../redis/client"; const ratelimitCache: Record = {}; diff --git a/packages/auth/src/leaderboard-hooks.ts b/packages/auth/src/leaderboard-hooks.ts new file mode 100644 index 00000000..b511690a --- /dev/null +++ b/packages/auth/src/leaderboard-hooks.ts @@ -0,0 +1,40 @@ +import { setUserMeta } from "@workspace/api/leaderboard/meta"; +import { syncUserLeaderboards } from "@workspace/api/leaderboard/redis"; +import { db } from "@workspace/db"; + +export async function onUserCreated(userId: string) { + await setUserMeta(userId, {}, { seedLeaderboards: true }); + await syncUserLeaderboards(db, userId); +} + +export async function onAccountLinked(args: { + userId: string; + provider: "github" | "gitlab"; + profile?: Record | null; +}) { + const { userId, provider, profile } = args; + + if (provider === "github") { + const login = + (profile?.["login"] as string) || + (profile?.["username"] as string) || + undefined; + + if (login) { + await setUserMeta(userId, { githubLogin: login }, { seedLeaderboards: true }); + await syncUserLeaderboards(db, userId); + } + } + + if (provider === "gitlab") { + const username = + (profile?.["username"] as string) || + (profile?.["preferred_username"] as string) || + undefined; + + if (username) { + await setUserMeta(userId, { gitlabUsername: username }, { seedLeaderboards: true }); + await syncUserLeaderboards(db, userId); + } + } +} diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 3707d499..15b91e3f 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -1,11 +1,22 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { secondaryStorage } from './secondary-storage'; +import { account } from '@workspace/db/schema'; import { env } from '@workspace/env/server'; import { admin } from 'better-auth/plugins'; import { betterAuth } from 'better-auth'; import { db } from '@workspace/db'; import 'server-only'; +import { setUserMetaFromProviders } from '@workspace/api/leaderboard/use-meta'; +import { setUserMeta } from '@workspace/api/leaderboard/meta'; +import { createAuthMiddleware } from 'better-auth/api'; +import { eq } from 'drizzle-orm'; + +const ORIGIN = + env.VERCEL_ENV === 'production' + ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` + : `http://${env.VERCEL_PROJECT_PRODUCTION_URL || 'localhost:3000'}`; + export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', @@ -16,6 +27,123 @@ export const auth = betterAuth({ adminRoles: ['admin', 'moderator'], }), ], + hooks: { + after: createAuthMiddleware(async (ctx) => { + const session = ctx.context.newSession; + if (!session) return; + + const userId = session.user.id; + const username = session.user?.username as string | undefined; + const avatar: string | null | undefined = session.user?.image ?? null; + + const newAccount = ctx?.context?.newAccount as + | { + providerId?: string; // 'github' | 'gitlab' + accountId?: string; // handle (sometimes numeric id depending on provider) + userId?: string; + accessToken?: string; + } + | undefined; + + async function githubIdToLogin(id: string): Promise { + try { + const res = await fetch(`https://api.github.com/user/${id}`, { + headers: { + 'User-Agent': 'ossdotnow', + Accept: 'application/vnd.github+json', + ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}), + }, + }); + if (!res.ok) return undefined; + const j = await res.json().catch(() => null); + return (j && typeof j.login === 'string' && j.login) || undefined; + } catch { + return undefined; + } + } + + let githubLogin: string | undefined; + let gitlabUsername: string | undefined; + + if (newAccount?.providerId === 'github') { + githubLogin = session.user?.username; + } else if (newAccount?.providerId === 'gitlab') { + gitlabUsername = session.user?.username; + } + + try { + await setUserMeta( + userId, + { + username, + avatar, + githubLogin, + gitlabUsername, + }, + { seedLeaderboards: false }, + ); + } catch (e) { + console.error('[auth] setUserMeta failed:', e); + } + + if (!githubLogin && !gitlabUsername) { + const links = await db + .select({ providerId: account.providerId, accountId: account.accountId }) + .from(account) + .where(eq(account.userId, userId)); + + for (const l of links) { + if (!githubLogin && l.providerId === 'github' && l.accountId) { + const raw = l.accountId.trim(); + githubLogin = /^\d+$/.test(raw) ? await githubIdToLogin(raw) : raw; + } + if (!gitlabUsername && l.providerId === 'gitlab' && l.accountId) { + gitlabUsername = l.accountId.trim(); + } + } + } + + try { + if (githubLogin || gitlabUsername) { + await setUserMetaFromProviders(userId, githubLogin, gitlabUsername); + } + } catch (e) { + console.error('[auth] setUserMetaFromProviders failed:', e); + } + + function backfill(body: unknown, label: string) { + return fetch(`${ORIGIN}/api/internal/leaderboard/backfill`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.CRON_SECRET}`, + }, + body: JSON.stringify(body), + }) + .then(async (r) => { + const text = await r.text().catch(() => ''); + console.log(`[auth] backfill ${label} ->`, r.status, text.slice(0, 200)); + return { ok: r.ok, status: r.status }; + }) + .catch((e) => { + console.warn(`[auth] backfill ${label} fetch failed:`, e); + return { ok: false, status: 0 }; + }); + } + + if (githubLogin || gitlabUsername) { + const body = { userId, githubLogin, gitlabUsername }; + + void backfill(body, 'rollups').then(async (res) => { + if (res.status === 409) { + setTimeout(() => { + void backfill(body, 'rollups retry'); + }, 60_000); + } + }); + } + }), + }, socialProviders: { github: { diff --git a/packages/db/drizzle/0022_green_rattler.sql b/packages/db/drizzle/0022_green_rattler.sql deleted file mode 100644 index 8e04c2bd..00000000 --- a/packages/db/drizzle/0022_green_rattler.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE "project_comment_like" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "comment_id" uuid NOT NULL, - "user_id" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "project_comment_like" ADD CONSTRAINT "project_comment_like_comment_id_project_comment_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."project_comment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "project_comment_like" ADD CONSTRAINT "project_comment_like_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "project_comment_like_comment_id_idx" ON "project_comment_like" USING btree ("comment_id");--> statement-breakpoint -CREATE INDEX "project_comment_like_user_id_idx" ON "project_comment_like" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "project_comment_like_unique_idx" ON "project_comment_like" USING btree ("comment_id","user_id"); diff --git a/packages/db/drizzle/0022_huge_piledriver.sql b/packages/db/drizzle/0022_huge_piledriver.sql new file mode 100644 index 00000000..de960a1b --- /dev/null +++ b/packages/db/drizzle/0022_huge_piledriver.sql @@ -0,0 +1,47 @@ +CREATE TYPE "public"."notification_type" AS ENUM('launch_scheduled', 'launch_live', 'comment_received');--> statement-breakpoint +CREATE TYPE "public"."contrib_period" AS ENUM('all_time', 'last_30d', 'last_365d');--> statement-breakpoint +CREATE TYPE "public"."contrib_provider" AS ENUM('github', 'gitlab');--> statement-breakpoint +CREATE TABLE "project_comment_like" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "comment_id" uuid NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "type" "notification_type" NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "data" jsonb, + "read" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "contrib_rollups" ( + "user_id" text NOT NULL, + "period" "contrib_period" NOT NULL, + "commits" integer DEFAULT 0 NOT NULL, + "prs" integer DEFAULT 0 NOT NULL, + "issues" integer DEFAULT 0 NOT NULL, + "total" integer DEFAULT 0 NOT NULL, + "fetched_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "project" ADD COLUMN "repo_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "project_comment_like" ADD CONSTRAINT "project_comment_like_comment_id_project_comment_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."project_comment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project_comment_like" ADD CONSTRAINT "project_comment_like_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "project_comment_like_comment_id_idx" ON "project_comment_like" USING btree ("comment_id");--> statement-breakpoint +CREATE INDEX "project_comment_like_user_id_idx" ON "project_comment_like" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "project_comment_like_unique_idx" ON "project_comment_like" USING btree ("comment_id","user_id");--> statement-breakpoint +CREATE INDEX "notification_user_id_idx" ON "notification" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "notification_read_idx" ON "notification" USING btree ("read");--> statement-breakpoint +CREATE INDEX "notification_type_idx" ON "notification" USING btree ("type");--> statement-breakpoint +CREATE INDEX "notification_created_at_idx" ON "notification" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE UNIQUE INDEX "contrib_rollups_user_period_uidx" ON "contrib_rollups" USING btree ("user_id","period");--> statement-breakpoint +CREATE INDEX "contrib_rollups_period_idx" ON "contrib_rollups" USING btree ("period");--> statement-breakpoint +CREATE INDEX "contrib_rollups_user_idx" ON "contrib_rollups" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/drizzle/0022_premium_zaladane.sql b/packages/db/drizzle/0022_premium_zaladane.sql deleted file mode 100644 index f6eaed48..00000000 --- a/packages/db/drizzle/0022_premium_zaladane.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TYPE "public"."notification_type" AS ENUM('launch_scheduled', 'launch_live', 'comment_received');--> statement-breakpoint -CREATE TABLE "notification" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" text NOT NULL, - "type" "notification_type" NOT NULL, - "title" text NOT NULL, - "message" text NOT NULL, - "data" jsonb, - "read" boolean DEFAULT false NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "notification_user_id_idx" ON "notification" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "notification_read_idx" ON "notification" USING btree ("read");--> statement-breakpoint -CREATE INDEX "notification_type_idx" ON "notification" USING btree ("type");--> statement-breakpoint -CREATE INDEX "notification_created_at_idx" ON "notification" USING btree ("created_at" DESC NULLS LAST); \ No newline at end of file diff --git a/packages/db/drizzle/0023_real_wither.sql b/packages/db/drizzle/0023_real_wither.sql deleted file mode 100644 index 6012b9ab..00000000 --- a/packages/db/drizzle/0023_real_wither.sql +++ /dev/null @@ -1,55 +0,0 @@ -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN - CREATE TYPE "public"."notification_type" AS ENUM('launch_scheduled', 'launch_live', 'comment_received'); - END IF; -END $$; -CREATE TABLE IF NOT EXISTS "notification" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" text NOT NULL, - "type" "notification_type" NOT NULL, - "title" text NOT NULL, - "message" text NOT NULL, - "data" jsonb, - "read" boolean DEFAULT false NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'notification_user_id_user_id_fk' - AND table_name = 'notification' - ) THEN - ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; - END IF; -END $$; -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'notification_user_id_idx') THEN - CREATE INDEX "notification_user_id_idx" ON "notification" USING btree ("user_id"); - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'notification_read_idx') THEN - CREATE INDEX "notification_read_idx" ON "notification" USING btree ("read"); - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'notification_type_idx') THEN - CREATE INDEX "notification_type_idx" ON "notification" USING btree ("type"); - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'notification_created_at_idx') THEN - CREATE INDEX "notification_created_at_idx" ON "notification" USING btree ("created_at" DESC NULLS LAST); - END IF; -END $$; diff --git a/packages/db/drizzle/0023_spotty_sharon_carter.sql b/packages/db/drizzle/0023_spotty_sharon_carter.sql deleted file mode 100644 index 7d11a1c1..00000000 --- a/packages/db/drizzle/0023_spotty_sharon_carter.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX "project_comment_like_unique_idx";--> statement-breakpoint -CREATE UNIQUE INDEX "project_comment_like_unique_idx" ON "project_comment_like" USING btree ("comment_id","user_id"); \ No newline at end of file diff --git a/packages/db/drizzle/0024_free_khan.sql b/packages/db/drizzle/0024_free_khan.sql deleted file mode 100644 index cc81721d..00000000 --- a/packages/db/drizzle/0024_free_khan.sql +++ /dev/null @@ -1,17 +0,0 @@ --- First, add the column without NOT NULL constraint -ALTER TABLE "project" ADD COLUMN IF NOT EXISTS "repo_id" text; - --- Set default values for existing records based on git_host and git_repo_url -UPDATE "project" -SET "repo_id" = - CASE - WHEN "git_host" IS NOT NULL THEN "git_host" || ':' || "git_repo_url" - ELSE 'unknown:' || "git_repo_url" - END -WHERE "repo_id" IS NULL; - --- After all values are populated, add the NOT NULL constraint -ALTER TABLE "project" ALTER COLUMN "repo_id" SET NOT NULL; - --- Add an index for better query performance -CREATE INDEX IF NOT EXISTS "project_repo_id_idx" ON "project" ("repo_id"); diff --git a/packages/db/drizzle/0025_add_repo_id_index.sql b/packages/db/drizzle/0025_add_repo_id_index.sql deleted file mode 100644 index df53c9b1..00000000 --- a/packages/db/drizzle/0025_add_repo_id_index.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add an index for better repo_id query performance -CREATE INDEX IF NOT EXISTS "project_repo_id_idx" ON "project" ("repo_id"); diff --git a/packages/db/drizzle/meta/0022_snapshot.json b/packages/db/drizzle/meta/0022_snapshot.json index de7480e4..a8197751 100644 --- a/packages/db/drizzle/meta/0022_snapshot.json +++ b/packages/db/drizzle/meta/0022_snapshot.json @@ -1,6 +1,6 @@ { - "id": "c6c7a56c-98a0-445f-9b9d-9c2df0abd786", - "prevId": "50db695f-866a-4189-9750-30f76c604da1", + "id": "1fa4fa3c-2bec-4794-ab67-2022c8fd6167", + "prevId": "814e9fd6-ff3a-4057-9b76-33a82118ba0f", "version": "7", "dialect": "postgresql", "tables": { @@ -2202,6 +2202,126 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.contrib_rollups": { + "name": "contrib_rollups", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "contrib_period", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "commits": { + "name": "commits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prs": { + "name": "prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "issues": { + "name": "issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contrib_rollups_user_period_uidx": { + "name": "contrib_rollups_user_period_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_rollups_period_idx": { + "name": "contrib_rollups_period_idx", + "columns": [ + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_rollups_user_idx": { + "name": "contrib_rollups_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": { @@ -2273,6 +2393,23 @@ "launch_live", "comment_received" ] + }, + "public.contrib_period": { + "name": "contrib_period", + "schema": "public", + "values": [ + "all_time", + "last_30d", + "last_365d" + ] + }, + "public.contrib_provider": { + "name": "contrib_provider", + "schema": "public", + "values": [ + "github", + "gitlab" + ] } }, "schemas": {}, diff --git a/packages/db/drizzle/meta/0023_snapshot.json b/packages/db/drizzle/meta/0023_snapshot.json deleted file mode 100644 index 2ea11bf1..00000000 --- a/packages/db/drizzle/meta/0023_snapshot.json +++ /dev/null @@ -1,2288 +0,0 @@ -{ - "id": "10adf0ac-97f3-47c1-9bf1-7a345dc4a7cc", - "prevId": "50db695f-866a-4189-9750-30f76c604da1", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.waitlist": { - "name": "waitlist", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "waitlist_email_unique": { - "name": "waitlist_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "impersonated_by": { - "name": "impersonated_by", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "user_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'user'" - }, - "banned": { - "name": "banned", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "ban_reason": { - "name": "ban_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ban_expires": { - "name": "ban_expires", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.competitor": { - "name": "competitor", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "logo_url": { - "name": "logo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "git_repo_url": { - "name": "git_repo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "git_host": { - "name": "git_host", - "type": "git_host", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "social_links": { - "name": "social_links", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.competitor_tag_relations": { - "name": "competitor_tag_relations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "competitor_id": { - "name": "competitor_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "competitor_tag_relations_competitor_id_idx": { - "name": "competitor_tag_relations_competitor_id_idx", - "columns": [ - { - "expression": "competitor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "competitor_tag_relations_tag_id_idx": { - "name": "competitor_tag_relations_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "unique_competitor_tag": { - "name": "unique_competitor_tag", - "columns": [ - { - "expression": "competitor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "competitor_tag_relations_competitor_id_competitor_id_fk": { - "name": "competitor_tag_relations_competitor_id_competitor_id_fk", - "tableFrom": "competitor_tag_relations", - "tableTo": "competitor", - "columnsFrom": [ - "competitor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "competitor_tag_relations_tag_id_category_tags_id_fk": { - "name": "competitor_tag_relations_tag_id_category_tags_id_fk", - "tableFrom": "competitor_tag_relations", - "tableTo": "category_tags", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project": { - "name": "project", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo_url": { - "name": "logo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "git_repo_url": { - "name": "git_repo_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "git_host": { - "name": "git_host", - "type": "git_host", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "repo_id": { - "name": "repo_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "social_links": { - "name": "social_links", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "approval_status": { - "name": "approval_status", - "type": "project_approval_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "status_id": { - "name": "status_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type_id": { - "name": "type_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "is_looking_for_contributors": { - "name": "is_looking_for_contributors", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_looking_for_investors": { - "name": "is_looking_for_investors", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_hiring": { - "name": "is_hiring", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_public": { - "name": "is_public", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "has_been_acquired": { - "name": "has_been_acquired", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_pinned": { - "name": "is_pinned", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_repo_private": { - "name": "is_repo_private", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "acquired_by": { - "name": "acquired_by", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "stars_count": { - "name": "stars_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "stars_updated_at": { - "name": "stars_updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "forks_count": { - "name": "forks_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "forks_updated_at": { - "name": "forks_updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_status_id_idx": { - "name": "project_status_id_idx", - "columns": [ - { - "expression": "status_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_type_id_idx": { - "name": "project_type_id_idx", - "columns": [ - { - "expression": "type_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_stars_count_desc_idx": { - "name": "project_stars_count_desc_idx", - "columns": [ - { - "expression": "stars_count", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_forks_count_desc_idx": { - "name": "project_forks_count_desc_idx", - "columns": [ - { - "expression": "forks_count", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_owner_id_user_id_fk": { - "name": "project_owner_id_user_id_fk", - "tableFrom": "project", - "tableTo": "user", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "project_status_id_category_project_statuses_id_fk": { - "name": "project_status_id_category_project_statuses_id_fk", - "tableFrom": "project", - "tableTo": "category_project_statuses", - "columnsFrom": [ - "status_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "project_type_id_category_project_types_id_fk": { - "name": "project_type_id_category_project_types_id_fk", - "tableFrom": "project", - "tableTo": "category_project_types", - "columnsFrom": [ - "type_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "project_acquired_by_competitor_id_fk": { - "name": "project_acquired_by_competitor_id_fk", - "tableFrom": "project", - "tableTo": "competitor", - "columnsFrom": [ - "acquired_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_git_repo_url_unique": { - "name": "project_git_repo_url_unique", - "nullsNotDistinct": false, - "columns": [ - "git_repo_url" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_tag_relations": { - "name": "project_tag_relations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_tag_relations_project_id_idx": { - "name": "project_tag_relations_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_tag_relations_tag_id_idx": { - "name": "project_tag_relations_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "unique_project_tag": { - "name": "unique_project_tag", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_tag_relations_project_id_project_id_fk": { - "name": "project_tag_relations_project_id_project_id_fk", - "tableFrom": "project_tag_relations", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_tag_relations_tag_id_category_tags_id_fk": { - "name": "project_tag_relations_tag_id_category_tags_id_fk", - "tableFrom": "project_tag_relations", - "tableTo": "category_tags", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_competitors": { - "name": "project_competitors", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "alternative_competitor_type": { - "name": "alternative_competitor_type", - "type": "alternative_competitor_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "alternative_project_id": { - "name": "alternative_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "alternative_competitor_id": { - "name": "alternative_competitor_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_competitors_project_id_idx": { - "name": "project_competitors_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_competitors_alt_project_id_idx": { - "name": "project_competitors_alt_project_id_idx", - "columns": [ - { - "expression": "alternative_project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_competitors_alt_competitor_id_idx": { - "name": "project_competitors_alt_competitor_id_idx", - "columns": [ - { - "expression": "alternative_competitor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_competitors_project_id_project_id_fk": { - "name": "project_competitors_project_id_project_id_fk", - "tableFrom": "project_competitors", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_competitors_alternative_project_id_project_id_fk": { - "name": "project_competitors_alternative_project_id_project_id_fk", - "tableFrom": "project_competitors", - "tableTo": "project", - "columnsFrom": [ - "alternative_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_competitors_alternative_competitor_id_competitor_id_fk": { - "name": "project_competitors_alternative_competitor_id_competitor_id_fk", - "tableFrom": "project_competitors", - "tableTo": "competitor", - "columnsFrom": [ - "alternative_competitor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_claim": { - "name": "project_claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "success": { - "name": "success", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "verification_method": { - "name": "verification_method", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "verification_details": { - "name": "verification_details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error_reason": { - "name": "error_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "project_claim_project_id_project_id_fk": { - "name": "project_claim_project_id_project_id_fk", - "tableFrom": "project_claim", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_claim_user_id_user_id_fk": { - "name": "project_claim_user_id_user_id_fk", - "tableFrom": "project_claim", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.category_project_statuses": { - "name": "category_project_statuses", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "category_project_statuses_name_unique": { - "name": "category_project_statuses_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.category_project_types": { - "name": "category_project_types", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "category_project_types_name_unique": { - "name": "category_project_types_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.category_tags": { - "name": "category_tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "category_tags_name_unique": { - "name": "category_tags_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_launch": { - "name": "project_launch", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tagline": { - "name": "tagline", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "detailed_description": { - "name": "detailed_description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "launch_date": { - "name": "launch_date", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "status": { - "name": "status", - "type": "launch_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'scheduled'" - }, - "featured": { - "name": "featured", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_launch_project_id_idx": { - "name": "project_launch_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_launch_launch_date_idx": { - "name": "project_launch_launch_date_idx", - "columns": [ - { - "expression": "launch_date", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_launch_status_idx": { - "name": "project_launch_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_launch_project_id_project_id_fk": { - "name": "project_launch_project_id_project_id_fk", - "tableFrom": "project_launch", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_launch_project_id_unique": { - "name": "project_launch_project_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_vote": { - "name": "project_vote", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_vote_project_id_idx": { - "name": "project_vote_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_vote_user_id_idx": { - "name": "project_vote_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_vote_project_id_project_id_fk": { - "name": "project_vote_project_id_project_id_fk", - "tableFrom": "project_vote", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_vote_user_id_user_id_fk": { - "name": "project_vote_user_id_user_id_fk", - "tableFrom": "project_vote", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_vote_project_id_user_id_unique": { - "name": "project_vote_project_id_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id", - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_comment": { - "name": "project_comment", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_id": { - "name": "parent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_comment_project_id_idx": { - "name": "project_comment_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_user_id_idx": { - "name": "project_comment_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_parent_id_idx": { - "name": "project_comment_parent_id_idx", - "columns": [ - { - "expression": "parent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_comment_project_id_project_id_fk": { - "name": "project_comment_project_id_project_id_fk", - "tableFrom": "project_comment", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_comment_user_id_user_id_fk": { - "name": "project_comment_user_id_user_id_fk", - "tableFrom": "project_comment", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_comment_parent_id_project_comment_id_fk": { - "name": "project_comment_parent_id_project_comment_id_fk", - "tableFrom": "project_comment", - "tableTo": "project_comment", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_comment_like": { - "name": "project_comment_like", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "comment_id": { - "name": "comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_comment_like_comment_id_idx": { - "name": "project_comment_like_comment_id_idx", - "columns": [ - { - "expression": "comment_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_like_user_id_idx": { - "name": "project_comment_like_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_like_unique_idx": { - "name": "project_comment_like_unique_idx", - "columns": [ - { - "expression": "comment_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_comment_like_comment_id_project_comment_id_fk": { - "name": "project_comment_like_comment_id_project_comment_id_fk", - "tableFrom": "project_comment_like", - "tableTo": "project_comment", - "columnsFrom": [ - "comment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_comment_like_user_id_user_id_fk": { - "name": "project_comment_like_user_id_user_id_fk", - "tableFrom": "project_comment_like", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_report": { - "name": "project_report", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_report_project_id_idx": { - "name": "project_report_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_report_user_id_idx": { - "name": "project_report_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_report_project_id_project_id_fk": { - "name": "project_report_project_id_project_id_fk", - "tableFrom": "project_report", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_report_user_id_user_id_fk": { - "name": "project_report_user_id_user_id_fk", - "tableFrom": "project_report", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_report_project_id_user_id_unique": { - "name": "project_report_project_id_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id", - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.notification": { - "name": "notification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "notification_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "read": { - "name": "read", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "notification_user_id_idx": { - "name": "notification_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "notification_read_idx": { - "name": "notification_read_idx", - "columns": [ - { - "expression": "read", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "notification_type_idx": { - "name": "notification_type_idx", - "columns": [ - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "notification_created_at_idx": { - "name": "notification_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "notification_user_id_user_id_fk": { - "name": "notification_user_id_user_id_fk", - "tableFrom": "notification", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.acquisition_type": { - "name": "acquisition_type", - "schema": "public", - "values": [ - "ipo", - "acquisition", - "other" - ] - }, - "public.project_approval_status": { - "name": "project_approval_status", - "schema": "public", - "values": [ - "pending", - "approved", - "rejected" - ] - }, - "public.alternative_competitor_type": { - "name": "alternative_competitor_type", - "schema": "public", - "values": [ - "project", - "competitor" - ] - }, - "public.git_host": { - "name": "git_host", - "schema": "public", - "values": [ - "github", - "gitlab" - ] - }, - "public.project_provider": { - "name": "project_provider", - "schema": "public", - "values": [ - "github", - "gitlab" - ] - }, - "public.user_role": { - "name": "user_role", - "schema": "public", - "values": [ - "admin", - "user", - "moderator" - ] - }, - "public.launch_status": { - "name": "launch_status", - "schema": "public", - "values": [ - "scheduled", - "live", - "ended" - ] - }, - "public.notification_type": { - "name": "notification_type", - "schema": "public", - "values": [ - "launch_scheduled", - "launch_live", - "comment_received" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0024_snapshot.json b/packages/db/drizzle/meta/0024_snapshot.json deleted file mode 100644 index df9fdc5a..00000000 --- a/packages/db/drizzle/meta/0024_snapshot.json +++ /dev/null @@ -1,2132 +0,0 @@ -{ - "id": "50db695f-866a-4189-9750-30f76c604da1", - "prevId": "73acb39c-b531-4c68-a607-f1a9d5a0d226", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.waitlist": { - "name": "waitlist", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "waitlist_email_unique": { - "name": "waitlist_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "impersonated_by": { - "name": "impersonated_by", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "user_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'user'" - }, - "banned": { - "name": "banned", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "ban_reason": { - "name": "ban_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ban_expires": { - "name": "ban_expires", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.competitor": { - "name": "competitor", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "logo_url": { - "name": "logo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "git_repo_url": { - "name": "git_repo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "git_host": { - "name": "git_host", - "type": "git_host", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "social_links": { - "name": "social_links", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.competitor_tag_relations": { - "name": "competitor_tag_relations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "competitor_id": { - "name": "competitor_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "competitor_tag_relations_competitor_id_idx": { - "name": "competitor_tag_relations_competitor_id_idx", - "columns": [ - { - "expression": "competitor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "competitor_tag_relations_tag_id_idx": { - "name": "competitor_tag_relations_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "unique_competitor_tag": { - "name": "unique_competitor_tag", - "columns": [ - { - "expression": "competitor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "competitor_tag_relations_competitor_id_competitor_id_fk": { - "name": "competitor_tag_relations_competitor_id_competitor_id_fk", - "tableFrom": "competitor_tag_relations", - "tableTo": "competitor", - "columnsFrom": [ - "competitor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "competitor_tag_relations_tag_id_category_tags_id_fk": { - "name": "competitor_tag_relations_tag_id_category_tags_id_fk", - "tableFrom": "competitor_tag_relations", - "tableTo": "category_tags", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project": { - "name": "project", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo_url": { - "name": "logo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "git_repo_url": { - "name": "git_repo_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "git_host": { - "name": "git_host", - "type": "git_host", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "repo_id": { - "name": "repo_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "social_links": { - "name": "social_links", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "approval_status": { - "name": "approval_status", - "type": "project_approval_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "status_id": { - "name": "status_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type_id": { - "name": "type_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "is_looking_for_contributors": { - "name": "is_looking_for_contributors", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_looking_for_investors": { - "name": "is_looking_for_investors", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_hiring": { - "name": "is_hiring", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_public": { - "name": "is_public", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "has_been_acquired": { - "name": "has_been_acquired", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_pinned": { - "name": "is_pinned", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_repo_private": { - "name": "is_repo_private", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "acquired_by": { - "name": "acquired_by", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "stars_count": { - "name": "stars_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "stars_updated_at": { - "name": "stars_updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "forks_count": { - "name": "forks_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "forks_updated_at": { - "name": "forks_updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_status_id_idx": { - "name": "project_status_id_idx", - "columns": [ - { - "expression": "status_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_type_id_idx": { - "name": "project_type_id_idx", - "columns": [ - { - "expression": "type_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_stars_count_desc_idx": { - "name": "project_stars_count_desc_idx", - "columns": [ - { - "expression": "stars_count", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_forks_count_desc_idx": { - "name": "project_forks_count_desc_idx", - "columns": [ - { - "expression": "forks_count", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_owner_id_user_id_fk": { - "name": "project_owner_id_user_id_fk", - "tableFrom": "project", - "tableTo": "user", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "project_status_id_category_project_statuses_id_fk": { - "name": "project_status_id_category_project_statuses_id_fk", - "tableFrom": "project", - "tableTo": "category_project_statuses", - "columnsFrom": [ - "status_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "project_type_id_category_project_types_id_fk": { - "name": "project_type_id_category_project_types_id_fk", - "tableFrom": "project", - "tableTo": "category_project_types", - "columnsFrom": [ - "type_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "project_acquired_by_competitor_id_fk": { - "name": "project_acquired_by_competitor_id_fk", - "tableFrom": "project", - "tableTo": "competitor", - "columnsFrom": [ - "acquired_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_git_repo_url_unique": { - "name": "project_git_repo_url_unique", - "nullsNotDistinct": false, - "columns": [ - "git_repo_url" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_tag_relations": { - "name": "project_tag_relations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_tag_relations_project_id_idx": { - "name": "project_tag_relations_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_tag_relations_tag_id_idx": { - "name": "project_tag_relations_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "unique_project_tag": { - "name": "unique_project_tag", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_tag_relations_project_id_project_id_fk": { - "name": "project_tag_relations_project_id_project_id_fk", - "tableFrom": "project_tag_relations", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_tag_relations_tag_id_category_tags_id_fk": { - "name": "project_tag_relations_tag_id_category_tags_id_fk", - "tableFrom": "project_tag_relations", - "tableTo": "category_tags", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_competitors": { - "name": "project_competitors", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "alternative_competitor_type": { - "name": "alternative_competitor_type", - "type": "alternative_competitor_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "alternative_project_id": { - "name": "alternative_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "alternative_competitor_id": { - "name": "alternative_competitor_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_competitors_project_id_idx": { - "name": "project_competitors_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_competitors_alt_project_id_idx": { - "name": "project_competitors_alt_project_id_idx", - "columns": [ - { - "expression": "alternative_project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_competitors_alt_competitor_id_idx": { - "name": "project_competitors_alt_competitor_id_idx", - "columns": [ - { - "expression": "alternative_competitor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_competitors_project_id_project_id_fk": { - "name": "project_competitors_project_id_project_id_fk", - "tableFrom": "project_competitors", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_competitors_alternative_project_id_project_id_fk": { - "name": "project_competitors_alternative_project_id_project_id_fk", - "tableFrom": "project_competitors", - "tableTo": "project", - "columnsFrom": [ - "alternative_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_competitors_alternative_competitor_id_competitor_id_fk": { - "name": "project_competitors_alternative_competitor_id_competitor_id_fk", - "tableFrom": "project_competitors", - "tableTo": "competitor", - "columnsFrom": [ - "alternative_competitor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_claim": { - "name": "project_claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "success": { - "name": "success", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "verification_method": { - "name": "verification_method", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "verification_details": { - "name": "verification_details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error_reason": { - "name": "error_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "project_claim_project_id_project_id_fk": { - "name": "project_claim_project_id_project_id_fk", - "tableFrom": "project_claim", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_claim_user_id_user_id_fk": { - "name": "project_claim_user_id_user_id_fk", - "tableFrom": "project_claim", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.category_project_statuses": { - "name": "category_project_statuses", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "category_project_statuses_name_unique": { - "name": "category_project_statuses_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.category_project_types": { - "name": "category_project_types", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "category_project_types_name_unique": { - "name": "category_project_types_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.category_tags": { - "name": "category_tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "category_tags_name_unique": { - "name": "category_tags_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_launch": { - "name": "project_launch", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tagline": { - "name": "tagline", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "detailed_description": { - "name": "detailed_description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "launch_date": { - "name": "launch_date", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "status": { - "name": "status", - "type": "launch_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'scheduled'" - }, - "featured": { - "name": "featured", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_launch_project_id_idx": { - "name": "project_launch_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_launch_launch_date_idx": { - "name": "project_launch_launch_date_idx", - "columns": [ - { - "expression": "launch_date", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_launch_status_idx": { - "name": "project_launch_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_launch_project_id_project_id_fk": { - "name": "project_launch_project_id_project_id_fk", - "tableFrom": "project_launch", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_launch_project_id_unique": { - "name": "project_launch_project_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_vote": { - "name": "project_vote", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_vote_project_id_idx": { - "name": "project_vote_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_vote_user_id_idx": { - "name": "project_vote_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_vote_project_id_project_id_fk": { - "name": "project_vote_project_id_project_id_fk", - "tableFrom": "project_vote", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_vote_user_id_user_id_fk": { - "name": "project_vote_user_id_user_id_fk", - "tableFrom": "project_vote", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_vote_project_id_user_id_unique": { - "name": "project_vote_project_id_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id", - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_comment": { - "name": "project_comment", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_id": { - "name": "parent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_comment_project_id_idx": { - "name": "project_comment_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_user_id_idx": { - "name": "project_comment_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_parent_id_idx": { - "name": "project_comment_parent_id_idx", - "columns": [ - { - "expression": "parent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_comment_project_id_project_id_fk": { - "name": "project_comment_project_id_project_id_fk", - "tableFrom": "project_comment", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_comment_user_id_user_id_fk": { - "name": "project_comment_user_id_user_id_fk", - "tableFrom": "project_comment", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_comment_parent_id_project_comment_id_fk": { - "name": "project_comment_parent_id_project_comment_id_fk", - "tableFrom": "project_comment", - "tableTo": "project_comment", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_comment_like": { - "name": "project_comment_like", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "comment_id": { - "name": "comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_comment_like_comment_id_idx": { - "name": "project_comment_like_comment_id_idx", - "columns": [ - { - "expression": "comment_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_like_user_id_idx": { - "name": "project_comment_like_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_comment_like_unique_idx": { - "name": "project_comment_like_unique_idx", - "columns": [ - { - "expression": "comment_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_comment_like_comment_id_project_comment_id_fk": { - "name": "project_comment_like_comment_id_project_comment_id_fk", - "tableFrom": "project_comment_like", - "tableTo": "project_comment", - "columnsFrom": [ - "comment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_comment_like_user_id_user_id_fk": { - "name": "project_comment_like_user_id_user_id_fk", - "tableFrom": "project_comment_like", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project_report": { - "name": "project_report", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_report_project_id_idx": { - "name": "project_report_project_id_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_report_user_id_idx": { - "name": "project_report_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_report_project_id_project_id_fk": { - "name": "project_report_project_id_project_id_fk", - "tableFrom": "project_report", - "tableTo": "project", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_report_user_id_user_id_fk": { - "name": "project_report_user_id_user_id_fk", - "tableFrom": "project_report", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_report_project_id_user_id_unique": { - "name": "project_report_project_id_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id", - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.acquisition_type": { - "name": "acquisition_type", - "schema": "public", - "values": [ - "ipo", - "acquisition", - "other" - ] - }, - "public.project_approval_status": { - "name": "project_approval_status", - "schema": "public", - "values": [ - "pending", - "approved", - "rejected" - ] - }, - "public.alternative_competitor_type": { - "name": "alternative_competitor_type", - "schema": "public", - "values": [ - "project", - "competitor" - ] - }, - "public.git_host": { - "name": "git_host", - "schema": "public", - "values": [ - "github", - "gitlab" - ] - }, - "public.project_provider": { - "name": "project_provider", - "schema": "public", - "values": [ - "github", - "gitlab" - ] - }, - "public.user_role": { - "name": "user_role", - "schema": "public", - "values": [ - "admin", - "user", - "moderator" - ] - }, - "public.launch_status": { - "name": "launch_status", - "schema": "public", - "values": [ - "scheduled", - "live", - "ended" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fa4a501a..1a51f34d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -159,16 +159,9 @@ { "idx": 22, "version": "7", - "when": 1756062828591, - "tag": "0022_premium_zaladane", - "breakpoints": true - }, - { - "idx": 23, - "version": "7", - "when": 1756063027028, - "tag": "0023_real_wither", + "when": 1756735398067, + "tag": "0022_huge_piledriver", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/contributions.ts b/packages/db/src/schema/contributions.ts new file mode 100644 index 00000000..d8ed23ea --- /dev/null +++ b/packages/db/src/schema/contributions.ts @@ -0,0 +1,43 @@ +import { + pgEnum, + pgTable, + text, + integer, + timestamp, + uniqueIndex, + index, +} from "drizzle-orm/pg-core"; + +export const contribProvider = pgEnum("contrib_provider", ["github", "gitlab"]); + +export const contribPeriod = pgEnum("contrib_period", [ + "all_time", + "last_30d", + "last_365d", +]); + +export const contribRollups = pgTable( + "contrib_rollups", + { + userId: text("user_id").notNull(), + + period: contribPeriod("period").notNull(), + + commits: integer("commits").notNull().default(0), + prs: integer("prs").notNull().default(0), + issues: integer("issues").notNull().default(0), + total: integer("total").notNull().default(0), + + fetchedAt: timestamp("fetched_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("contrib_rollups_user_period_uidx").on(t.userId, t.period), + index("contrib_rollups_period_idx").on(t.period), + index("contrib_rollups_user_idx").on(t.userId), + ], +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 1dda0af5..a3426cd4 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -13,3 +13,4 @@ export * from './project-comment-likes'; export * from './project-claims'; export * from './project-reports'; export * from './notifications'; +export * from './contributions'; diff --git a/packages/env/package.json b/packages/env/package.json index f15e4bfe..77a17b44 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -5,7 +5,8 @@ "exports": { "./client": "./src/client.ts", "./server": "./src/server.ts", - "./utils": "./src/utils.ts" + "./utils": "./src/utils.ts", + "./verify-cron": "./src/verifyCron.ts" }, "scripts": { "lint": "eslint ." diff --git a/packages/env/src/verifyCron.ts b/packages/env/src/verifyCron.ts new file mode 100644 index 00000000..d4452e46 --- /dev/null +++ b/packages/env/src/verifyCron.ts @@ -0,0 +1,9 @@ +import { env } from './server'; + +export function isCronAuthorized(headerValue: string | null | undefined) { + if (!headerValue) return false; + const token = headerValue.startsWith('Bearer ') + ? headerValue.slice('Bearer '.length).trim() + : headerValue.trim(); + return token.length > 0 && token === env.CRON_SECRET; +} diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index 239bdb07..f70b8842 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) { type={type} data-slot="input" className={cn( - 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', - 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', + 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', className, )} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index ea2a0216..6177e1c8 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -37,7 +37,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -64,7 +64,7 @@ function SelectContent({ = {}; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]!; + if (!a.startsWith('--')) continue; + const [k, v] = a.slice(2).split('='); + kv[k] = v ?? '1'; + } + if (!kv.login && argv[2] && !argv[2].startsWith('--')) { + kv.login = argv[2]!; + } + const parsed = ArgSchema.safeParse(kv); + if (!parsed.success) { + console.error('❌ Invalid arguments:\n', parsed.error.format()); + process.exit(1); + } + return parsed.data; +} + +function toDateOrThrow(s: string, label: string): Date { + const d = new Date(s); + if (Number.isNaN(d.getTime())) { + throw new Error(`Invalid ${label} date: ${JSON.stringify(s)}`); + } + return d; +} + +function rangeFromInputs(opts: { + from?: string; + to?: string; + days?: number; + preset?: '30d' | '365d'; +}): { from: Date; to: Date } { + const now = new Date(); + if (opts.from && opts.to) { + const from = toDateOrThrow(opts.from, 'from'); + const to = toDateOrThrow(opts.to, 'to'); + if (from.getTime() >= to.getTime()) { + throw new Error(`'from' must be < 'to'`); + } + return { from, to }; + } + + if (opts.preset === '30d') { + return { from: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), to: now }; + } + if (opts.preset === '365d') { + return { from: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000), to: now }; + } + + if (opts.days) { + return { from: new Date(now.getTime() - opts.days * 24 * 60 * 60 * 1000), to: now }; + } + + return { from: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), to: now }; +} + +async function main() { + const args = parseArgs(process.argv); + const { login } = args; + const { from, to } = rangeFromInputs({ + from: args.from, + to: args.to, + days: args.days, + preset: args.preset, + }); + + const token = args.token || process.env.GITHUB_TOKEN; + if (!token) { + console.error('❌ Missing GitHub token. Pass --token=... or set GITHUB_TOKEN in env.'); + process.exit(1); + } + + const res = await getGithubContributionTotals(login, { from, to }, token); + + const total = res.commits + res.prs + res.issues; + const out = { + login: res.login, + range: { from: from.toISOString(), to: to.toISOString() }, + totals: { + commits: res.commits, + prs: res.prs, + issues: res.issues, + total, + }, + rateLimit: res.rateLimit ?? null, + }; + + console.log(JSON.stringify(out, null, 2)); +} + +main().catch((err) => { + console.error('❌ Error:', err?.message || err); + process.exit(1); +}); diff --git a/scripts/lb-backfill-365-all.ts b/scripts/lb-backfill-365-all.ts new file mode 100644 index 00000000..5fb1751d --- /dev/null +++ b/scripts/lb-backfill-365-all.ts @@ -0,0 +1,284 @@ +#!/usr/bin/env bun +/** + * Batch backfill for all users in Redis. + * + * - Reads user IDs from Redis set: lb:users + * - Skips users already processed (tracked in: lb:backfill:done:) + * - Reads provider handles from: lb:user: (githubLogin / gitlabUsername) + * - Calls POST /api/internal/leaderboard/backfill for each user + * - Bounded concurrency; retries on 409/429/5xx with backoff + * + * Usage: + * bun scripts/lb-backfill-365-all.ts --days=365 --batch=150 --concurrency=4 --origin=http://localhost:3000 + * + * Env: + * CRON_SECRET (required) + * DATABASE_URL (not used here, only Redis + HTTP) + * UPSTASH_REDIS_REST_URL (required by your redis client) + * UPSTASH_REDIS_REST_TOKEN (required by your redis client) + * + * Notes: + * - This script is idempotent thanks to "done" set tracking. + * - You can re-run until it prints "All users processed". + */ + +import { redis } from '../packages/api/src/redis/client'; + +type Flags = { + days: number; + batch: number; + concurrency: number; + jitterMinMs: number; + jitterMaxMs: number; + origin: string; + dry: boolean; +}; + +function parseFlags(): Flags { + const args = new Map(); + for (const a of process.argv.slice(2)) { + const m = a.match(/^--([^=]+)=(.*)$/); + if (m) args.set(m[1], m[2]); + else if (a === '--dry') args.set('dry', 'true'); + } + + const envOrigin = + process.env.ORIGIN || + (process.env.VERCEL_ENV === 'production' + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : `http://${process.env.VERCEL_PROJECT_PRODUCTION_URL || 'localhost:3000'}`); + + return { + days: Number(args.get('days') ?? 365), + batch: Number(args.get('batch') ?? 150), + concurrency: Number(args.get('concurrency') ?? 4), + jitterMinMs: Number(args.get('jitterMinMs') ?? 80), + jitterMaxMs: Number(args.get('jitterMaxMs') ?? 220), + origin: String(args.get('origin') ?? envOrigin), + dry: (args.get('dry') ?? 'false') === 'true', + }; +} + +const USER_SET = 'lb:users'; +const META = (id: string) => `lb:user:${id}`; +const DONE = (days: number) => `lb:backfill:done:${days}`; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const jitter = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1)); + +function asTrimmed(v: unknown): string | undefined { + if (typeof v === 'string') return v.trim() || undefined; + if (v == null) return undefined; + if (typeof v === 'number' || typeof v === 'boolean') return String(v).trim() || undefined; + return undefined; +} + +async function fetchMetaFor( + ids: string[], +): Promise> { + const pipe = redis.pipeline(); + for (const id of ids) pipe.hgetall(META(id)); + const raw = await pipe.exec(); + + return raw.map((item: any, i: number) => { + const val = Array.isArray(item) ? item[1] : item; + const obj = (val && typeof val === 'object' ? val : {}) as Record; + return { + id: ids[i]!, + gh: asTrimmed(obj.githubLogin), + gl: asTrimmed(obj.gitlabUsername), + }; + }); +} + +async function filterUndone(ids: string[], days: number): Promise { + const pipe = redis.pipeline(); + for (const id of ids) pipe.sismember(DONE(days), id); + const res = await pipe.exec(); + return ids.filter((_, i) => { + const item = Array.isArray(res[i]) ? res[i][1] : res[i]; + return !item; + }); +} + +type BackfillResult = { ok: boolean; status: number; body?: any }; + +async function callBackfill( + origin: string, + cronSecret: string, + body: Record, +): Promise { + const url = `${origin}/api/internal/leaderboard/backfill`; + + let attempt = 0; + const maxAttempts = 4; + + while (true) { + attempt++; + try { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${cronSecret}`, + }, + body: JSON.stringify(body), + }); + + const text = await r.text().catch(() => ''); + let parsed: any = undefined; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = text; + } + + if (r.ok) return { ok: true, status: r.status, body: parsed }; + + if (r.status === 409 && attempt <= maxAttempts) { + console.warn(`[backfill] 409 conflict; retrying in 60s…`); + await sleep(60_000); + continue; + } + + if ((r.status === 429 || r.status === 403) && attempt <= maxAttempts) { + const backoff = 60_000 * attempt; + console.warn(`[backfill] ${r.status} rate-limited; retrying in ${backoff / 1000}s…`); + await sleep(backoff); + continue; + } + + if (r.status >= 500 && attempt <= maxAttempts) { + const backoff = 10_000 * attempt; + console.warn(`[backfill] ${r.status} server error; retrying in ${backoff / 1000}s…`); + await sleep(backoff); + continue; + } + + return { ok: false, status: r.status, body: parsed }; + } catch (e) { + if (attempt <= maxAttempts) { + const backoff = 5_000 * attempt; + console.warn( + `[backfill] fetch error (${(e as Error).message}); retrying in ${backoff / 1000}s…`, + ); + await sleep(backoff); + continue; + } + return { ok: false, status: 0, body: String(e) }; + } + } +} + +async function main() { + const flags = parseFlags(); + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + console.error('CRON_SECRET is required in env.'); + process.exit(1); + } + + console.log( + `Starting backfill: days=${flags.days} batch=${flags.batch} concurrency=${flags.concurrency} origin=${flags.origin} dry=${flags.dry}`, + ); + + await redis.ping(); + + const allIds = (await redis.smembers(USER_SET)).map(String); + if (allIds.length === 0) { + console.log(`No users found in Redis set ${USER_SET}. Seed it first.`); + return; + } + + while (true) { + const undone = await filterUndone(allIds, flags.days); + if (undone.length === 0) { + const totalDone = await redis.scard(DONE(flags.days)); + console.log(`✅ All users processed for ${flags.days}d. Done count = ${totalDone}.`); + break; + } + + const batch = undone.slice(0, flags.batch); + console.log(`Processing batch of ${batch.length} (remaining ~${undone.length})…`); + + const metas = await fetchMetaFor(batch); + const tasksQueue = metas.filter((m) => m.gh || m.gl); + const skipped = metas.length - tasksQueue.length; + if (skipped) console.log(`Skipping ${skipped} users without provider handles.`); + + let idx = 0; + const results: Array<{ id: string; ok: boolean; status: number }> = []; + + const workers = Array.from( + { length: Math.max(1, Math.min(flags.concurrency, 8)) }, + async () => { + while (true) { + const i = idx++; + if (i >= tasksQueue.length) break; + + const { id, gh, gl } = tasksQueue[i]!; + const body = { + userId: id, + githubLogin: gh, + gitlabUsername: gl, + days: flags.days, + concurrency: 4, + }; + + if (flags.dry) { + console.log(`[dry] would backfill ${id} (gh=${gh ?? '-'} gl=${gl ?? '-'})`); + results.push({ id, ok: true, status: 200 }); + continue; + } + + await sleep(jitter(flags.jitterMinMs, flags.jitterMaxMs)); + + const res = await callBackfill(flags.origin, cronSecret, body); + if (!res.ok) { + const msg = + typeof res.body === 'string' + ? res.body + : res.body?.error || res.body?.message || JSON.stringify(res.body ?? {}); + console.warn(`[backfill] user=${id} -> ${res.status} ${msg}`); + } else { + console.log(`[backfill] user=${id} -> OK (${res.status})`); + } + + results.push({ id, ok: res.ok, status: res.status }); + } + }, + ); + + await Promise.all(workers); + + const succeeded = results.filter((r) => r.ok).map((r) => r.id); + if (succeeded.length) { + if (flags.dry) { + console.log(`[dry] would mark done in ${DONE(flags.days)}: ${succeeded.join(', ')}`); + } else { + const pipe = redis.pipeline(); + for (const id of succeeded) pipe.sadd(DONE(flags.days), id); + await pipe.exec(); + } + } + + const failed = results.filter((r) => !r.ok).map((r) => r.id); + console.log( + `Batch complete: ok=${succeeded.length}, failed=${failed.length}, marked done=${flags.dry ? 0 : succeeded.length}.`, + ); + + if (succeeded.length === 0 && failed.length > 0) { + console.warn(`No successes in this batch; backing off 90s before next batch…`); + await sleep(90_000); + } + } + + const total = await redis.scard(USER_SET); + const done = await redis.scard(DONE(flags.days)); + console.log(`Finished. USER_SET=${total}, DONE(${flags.days})=${done}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/lb-seed-all-users.ts b/scripts/lb-seed-all-users.ts new file mode 100644 index 00000000..0f409200 --- /dev/null +++ b/scripts/lb-seed-all-users.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +import { drizzle } from 'drizzle-orm/postgres-js'; +import { inArray } from 'drizzle-orm'; + +import { user as userTable, account as accountTable } from '../packages/db/src/schema/auth'; +import { setUserMeta } from '../packages/api/src/leaderboard/meta'; +import { redis } from '../packages/api/src/redis/client'; + +const GH = 'github'; +const GL = 'gitlab'; +const USER_SET = 'lb:users'; +const META = (id: string) => `lb:user:${id}`; + +async function getPostgres() { + const mod = (await import('postgres')) as any; + return (mod.default ?? mod) as (url: string, opts?: any) => any; +} + +async function makeDb() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('Missing DATABASE_URL'); + + const postgres = await getPostgres(); + const needsSSL = /neon\.tech/i.test(url) || /sslmode=require/i.test(url); + const pg = postgres(url, needsSSL ? { ssl: 'require' as const } : {}); + const db = drizzle(pg); + return { db, pg }; +} + +function asStr(x: unknown): string | undefined { + if (x == null) return undefined; + if (typeof x === 'string') return x.trim() || undefined; + return String(x).trim() || undefined; +} + +async function main() { + const { db, pg } = await makeDb(); + try { + const users = await db + .select({ userId: userTable.id, username: userTable.username }) + .from(userTable); + + const userIds = Array.from(new Set(users.map((u) => u.userId))); + console.log(`Found ${userIds.length} users`); + + if (userIds.length === 0) return; + + const usernameByUserId = new Map(); + for (const u of users) { + usernameByUserId.set(u.userId, asStr(u.username)); + } + + const links = await db + .select({ + userId: accountTable.userId, + providerId: accountTable.providerId, + providerLogin: (accountTable as any).providerLogin, + }) + .from(accountTable) + .where(inArray(accountTable.userId, userIds)); + + const map = new Map(); + for (const id of userIds) { + map.set(id, { username: usernameByUserId.get(id) }); + } + + for (const l of links) { + const m = map.get(l.userId)!; + const login = asStr((l as any).providerLogin); + if (!login) continue; + + if (l.providerId === GH && !m.gh) m.gh = login; + if (l.providerId === GL && !m.gl) m.gl = login; + } + + const BATCH = 100; + let done = 0; + + for (let i = 0; i < userIds.length; i += BATCH) { + const chunk = userIds.slice(i, i + BATCH); + + await Promise.all( + chunk.map(async (id) => { + const username = usernameByUserId.get(id); + await setUserMeta(id, { username }, { seedLeaderboards: false }); + + await redis.sadd(USER_SET, id); + }), + ); + + done += chunk.length; + console.log(`Seeded ${done}/${userIds.length}`); + } + + const count = await redis.scard(USER_SET); + console.log(`✅ Redis set "${USER_SET}" now has ${count} members`); + } finally { + if (typeof (pg as any)?.end === 'function') { + await (pg as any).end({ timeout: 5 }).catch(() => {}); + } + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/vercel.json b/vercel.json index 443b6351..52887296 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/cron/activate-launches", "schedule": "* * * * *" + }, + { + "path": "/api/internal/leaderboard/cron/daily", + "schedule": "* * * * *" } ] }