Compare commits

...

3 Commits

Author SHA1 Message Date
valknar 48f7e71a8e fix: map total_goals → totalGoals in confederationStats resolver
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:06:22 +02:00
valknar 9926673ffd fix: add limit arg to Tournament.topScorers in GraphQL schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:05:31 +02:00
valknar 0eb0fb5ee4 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:<encoded-pass>@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 <noreply@anthropic.com>
2026-06-14 17:01:06 +02:00
6 changed files with 113 additions and 90 deletions
+10 -7
View File
@@ -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
+1 -2
View File
@@ -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}"
+6 -8
View File
@@ -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'
+94 -68
View File
@@ -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<string, unknown>
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<string, unknown>
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`
@@ -316,7 +337,12 @@ export const resolvers = {
GROUP BY t.confederation
ORDER BY appearances DESC
`)
return rows
return (rows as Record<string, unknown>[]).map(r => ({
confederation: r.confederation,
appearances: r.appearances,
titles: r.titles,
totalGoals: r.total_goals,
}))
},
async biggestWins(_: unknown, { limit = 10 }: { limit?: number }) {
const rows = await db.select().from(matches)
+1 -1
View File
@@ -10,7 +10,7 @@ export const typeDefs = /* GraphQL */ `
matchesCount: Int
totalGoals: Int
avgGoalsPerGame: Float
topScorers: [ScorerEntry!]!
topScorers(limit: Int): [ScorerEntry!]!
matches: [Match!]!
}
+1 -4
View File
@@ -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...')