From 0eb0fb5ee41814369629e2b36175abb64417e122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 14 Jun 2026 17:01:06 +0200 Subject: [PATCH] fix: use percent-encoded DATABASE_URL instead of split DB_PASSWORD trick Drizzle ORM mutates client.options (parsers/serializers) after the postgres client is created, which causes the separately-passed password option to be lost on the actual connection attempt. Root cause confirmed on VPS: raw postgres.js query succeeded while drizzle.execute() failed with auth error. Fix: encode the password directly in DATABASE_URL (%23 = #, %5D = ], %3D = =). postgres.js decodes percent-encoding correctly. No separate DB_PASSWORD env var needed in the app container anymore. DB_PASSWORD is still used by the Postgres container (POSTGRES_PASSWORD). Coolify env var to set: DATABASE_URL=postgres://wc:@db:5432/worldcup Also adds resolver-level isMissingTable() guards so the app returns empty results instead of GraphQL errors on a fresh deploy before sync runs. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 17 ++-- docker-compose.yml | 3 +- lib/db/index.ts | 14 ++- lib/graphql/resolvers/index.ts | 155 +++++++++++++++++++-------------- scripts/sync.ts | 5 +- 5 files changed, 106 insertions(+), 88 deletions(-) diff --git a/.env.example b/.env.example index 7558b7b..2ff98bc 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,13 @@ # ── Production (Coolify) ──────────────────────────────────────────────────── -# DB_PASSWORD is passed separately so special characters never need URL-encoding. -# DATABASE_URL is constructed inside docker-compose.yml and does NOT need to be -# set in Coolify — only DB_PASSWORD is required. +# DATABASE_URL must include the password. Special characters must be +# percent-encoded so the URL parser handles them correctly: +# # → %23 ] → %5D = → %3D @ → %40 +# +# Example with password "p#ss]w=rd": +# DATABASE_URL=postgres://wc:p%23ss%5Dw%3Drd@db:5432/worldcup +# +# DB_PASSWORD is used ONLY by the Postgres container (no encoding needed). +DATABASE_URL=postgres://wc:changeme@db:5432/worldcup DB_PASSWORD=changeme # Traefik (set TRAEFIK_ENABLED=true when deploying behind Traefik) @@ -9,8 +15,5 @@ TRAEFIK_ENABLED=false TRAEFIK_HOST=worldcup.example.com NETWORK_NAME=traefik-network -# ── Local development ──────────────────────────────────────────────────────── -# Set DATABASE_URL when running pnpm dev or pnpm sync on the host directly. -# The password can be plain-text here since it goes through the postgres driver, -# not URL parsing, when DB_PASSWORD is unset. +# ── Local development ───────────────────────────────────────────────────────── # DATABASE_URL=postgres://wc:wc@localhost:5432/worldcup diff --git a/docker-compose.yml b/docker-compose.yml index 956651f..c8b359d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,7 @@ services: db: condition: service_healthy environment: - DATABASE_URL: postgres://wc@db:5432/worldcup - DB_PASSWORD: ${DB_PASSWORD} + DATABASE_URL: ${DATABASE_URL} NODE_ENV: production labels: - "traefik.enable=${TRAEFIK_ENABLED:-false}" diff --git a/lib/db/index.ts b/lib/db/index.ts index 94370e6..84b94bf 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -2,14 +2,12 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import * as schema from './schema' -const connectionString = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup' - -// DB_PASSWORD is passed separately to avoid URL-encoding issues with special chars -// (e.g. #, ], = in passwords break URL parsing). Falls back to password in DATABASE_URL for local dev. -const client = postgres(connectionString, { - max: 10, - ...(process.env.DB_PASSWORD ? { password: process.env.DB_PASSWORD } : {}), -}) +// Passwords with special chars (#, ], =) must be percent-encoded in DATABASE_URL. +// postgres.js decodes them correctly (e.g. %23 → #). No separate DB_PASSWORD needed. +const client = postgres( + process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup', + { max: 10 } +) export const db = drizzle(client, { schema }) export * from './schema' diff --git a/lib/graphql/resolvers/index.ts b/lib/graphql/resolvers/index.ts index 5d41ef9..5a71e97 100644 --- a/lib/graphql/resolvers/index.ts +++ b/lib/graphql/resolvers/index.ts @@ -59,74 +59,98 @@ async function hydrateMatch(row: typeof matches.$inferSelect) { } } +function isMissingTable(e: unknown): boolean { + const msg = e instanceof Error ? e.message : String(e) + return msg.includes('relation') && msg.includes('does not exist') +} + export const resolvers = { Query: { async tournaments() { - const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year)) - return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })) + try { + const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year)) + return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async tournament(_: unknown, { year }: { year: number }) { - const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1) - if (!rows[0]) return null - return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null } + try { + const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1) + if (!rows[0]) return null + return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null } + } catch (e) { if (isMissingTable(e)) return null; throw e } }, async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) { - const conditions = [] - if (args.year) conditions.push(eq(matches.tournamentYear, args.year)) - if (args.group) conditions.push(eq(matches.groupName, args.group)) - if (args.round) conditions.push(eq(matches.round, args.round)) - if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali)) - const rows = await db.select().from(matches) - .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy(asc(matches.date), asc(matches.id)) - return Promise.all(rows.map(hydrateMatch)) + try { + const conditions = [] + if (args.year) conditions.push(eq(matches.tournamentYear, args.year)) + if (args.group) conditions.push(eq(matches.groupName, args.group)) + if (args.round) conditions.push(eq(matches.round, args.round)) + if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali)) + const rows = await db.select().from(matches) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(asc(matches.date), asc(matches.id)) + return Promise.all(rows.map(hydrateMatch)) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async match(_: unknown, { id }: { id: number }) { - const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1) - return rows[0] ? hydrateMatch(rows[0]) : null + try { + const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1) + return rows[0] ? hydrateMatch(rows[0]) : null + } catch (e) { if (isMissingTable(e)) return null; throw e } }, async liveMatches() { - const today = new Date().toISOString().slice(0, 10) - const rows = await db.select().from(matches) - .where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false))) - .orderBy(asc(matches.id)) - const hydrated = await Promise.all(rows.map(hydrateMatch)) - return hydrated.filter(m => m.isLive) + try { + const today = new Date().toISOString().slice(0, 10) + const rows = await db.select().from(matches) + .where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false))) + .orderBy(asc(matches.id)) + const hydrated = await Promise.all(rows.map(hydrateMatch)) + return hydrated.filter(m => m.isLive) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) { - const today = new Date().toISOString().slice(0, 10) - const rows = await db.select().from(matches) - .where(and( - lt(matches.date, today), - isNotNull(matches.scoreFtHome), - eq(matches.isQualiPlayoff, false), - )) - .orderBy(desc(matches.date), desc(matches.id)) - .limit(limit) - return Promise.all(rows.map(hydrateMatch)) + try { + const today = new Date().toISOString().slice(0, 10) + const rows = await db.select().from(matches) + .where(and( + lt(matches.date, today), + isNotNull(matches.scoreFtHome), + eq(matches.isQualiPlayoff, false), + )) + .orderBy(desc(matches.date), desc(matches.id)) + .limit(limit) + return Promise.all(rows.map(hydrateMatch)) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) { - const today = new Date().toISOString().slice(0, 10) - const rows = await db.select().from(matches) - .where(and( - gte(matches.date, today), - sql`${matches.scoreFtHome} IS NULL`, - eq(matches.isQualiPlayoff, false), - )) - .orderBy(asc(matches.date), asc(matches.id)) - .limit(limit) - return Promise.all(rows.map(hydrateMatch)) + try { + const today = new Date().toISOString().slice(0, 10) + const rows = await db.select().from(matches) + .where(and( + gte(matches.date, today), + sql`${matches.scoreFtHome} IS NULL`, + eq(matches.isQualiPlayoff, false), + )) + .orderBy(asc(matches.date), asc(matches.id)) + .limit(limit) + return Promise.all(rows.map(hydrateMatch)) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async teams() { - const rows = await db.select().from(teams).orderBy(asc(teams.name)) - return rows.map(teamWithSlug) + try { + const rows = await db.select().from(teams).orderBy(asc(teams.name)) + return rows.map(teamWithSlug) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async team(_: unknown, { slug }: { slug: string }) { - const rows = await db.select().from(teams) - const found = rows.find(r => slugify(r.name) === slug) - return found ? teamWithSlug(found) : null + try { + const rows = await db.select().from(teams) + const found = rows.find(r => slugify(r.name) === slug) + return found ? teamWithSlug(found) : null + } catch (e) { if (isMissingTable(e)) return null; throw e } }, async topScorers(_: unknown, { year, limit = 20 }: { year?: number; limit?: number }) { + try { const conditions = year ? sql`AND m.tournament_year = ${year} AND m.is_quali_playoff = false` : sql`AND m.is_quali_playoff = false` @@ -153,6 +177,7 @@ export const resolvers = { tournaments: r.tournaments, team: r.team_id ? await getTeamById(r.team_id as number) : null, }))) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async player(_: unknown, { name }: { name: string }) { const rows = await db.execute(sql` @@ -210,6 +235,7 @@ export const resolvers = { }))) }, async groupStandings(_: unknown, { year }: { year: number }) { + try { const rows = await db.select({ groupName: groupStandings.groupName, pos: groupStandings.pos, @@ -237,6 +263,7 @@ export const resolvers = { pts: r.pts ?? 0, team: (await getTeamById(r.teamId))!, }))) + } catch (e) { if (isMissingTable(e)) return []; throw e } }, async stadiums(_: unknown, { year }: { year?: number }) { const rows = year @@ -255,26 +282,20 @@ export const resolvers = { })) }, async tournamentStats() { - const [totals] = await db.execute(sql` - SELECT - COUNT(DISTINCT t.year)::int AS total_tournaments, - COUNT(DISTINCT m.id)::int AS total_matches, - COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals, - ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals - FROM tournaments t - LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL - `) - const r = totals as Record - return { - totalTournaments: r.total_tournaments, - totalMatches: r.total_matches, - totalGoals: r.total_goals, - avgGoalsPerGame: r.avg_goals, - mostGoalsInTournament: null, - highestScoringMatch: null, - biggestWin: null, - mostTitles: null, - } + const empty = { totalTournaments: 0, totalMatches: 0, totalGoals: 0, avgGoalsPerGame: null, mostGoalsInTournament: null, highestScoringMatch: null, biggestWin: null, mostTitles: null } + try { + const [totals] = await db.execute(sql` + SELECT + COUNT(DISTINCT t.year)::int AS total_tournaments, + COUNT(DISTINCT m.id)::int AS total_matches, + COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals, + ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals + FROM tournaments t + LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL + `) + const r = totals as Record + return { ...empty, totalTournaments: r.total_tournaments, totalMatches: r.total_matches, totalGoals: r.total_goals, avgGoalsPerGame: r.avg_goals } + } catch (e) { if (isMissingTable(e)) return empty; throw e } }, async goalsByMinute() { const rows = await db.execute(sql` diff --git a/scripts/sync.ts b/scripts/sync.ts index 7edb87e..cbaeb19 100644 --- a/scripts/sync.ts +++ b/scripts/sync.ts @@ -73,10 +73,7 @@ function parseScore(score: RawScore | undefined) { } async function run() { - const client = postgres(DATABASE_URL, { - max: 5, - ...(process.env.DB_PASSWORD ? { password: process.env.DB_PASSWORD } : {}), - }) + const client = postgres(DATABASE_URL, { max: 5 }) const db = drizzle(client) console.log('Creating tables...')