data(*): Add working regenerated data structure

This commit is contained in:
Josh Creek
2026-01-17 20:39:55 +00:00
parent a604fe2526
commit f302721119
19 changed files with 8340 additions and 9943 deletions
+1391 -2729
View File
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@
catchRecord = {
_id: '', // Empty string, not temp ID - will be created by server
userId: userId || '',
pokedexEntryId: pokedexEntry._id,
pokemonId: pokedexEntry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
+1
View File
@@ -3,6 +3,7 @@ export interface CatchRecord {
_id: string; // Maps to Supabase 'id' field for frontend compatibility
userId: string;
pokemonId: string;
pokedexId: string;
haveToEvolve: boolean;
caught: boolean;
inHome: boolean;
@@ -16,6 +16,7 @@ class CatchRecordRepository {
_id: record.id,
userId: record.userId,
pokemonId: record.pokemonId.toString(),
pokedexId: record.pokedexId,
haveToEvolve: record.haveToEvolve,
caught: record.caught,
inHome: record.inHome,
+124 -62
View File
@@ -4,6 +4,8 @@ import { type CombinedData } from '$lib/models/CombinedData';
import type { SupabaseClient } from '@supabase/supabase-js';
class CombinedDataRepository {
private static readonly MAX_ROWS_PER_REQUEST = 1000;
constructor(
private supabase: SupabaseClient,
private userId: string | null,
@@ -31,8 +33,7 @@ class CombinedDataRepository {
return {
_id: record.id,
userId: record.userId,
// Backwards-compatible field name; DB column is pokemonId
pokedexEntryId: record.pokemonId.toString(),
pokemonId: record.pokemonId.toString(),
pokedexId: record.pokedexId,
haveToEvolve: record.haveToEvolve,
caught: record.caught,
@@ -42,15 +43,9 @@ class CombinedDataRepository {
};
}
async findAllCombinedData(
userId: string,
enableForms: boolean = true,
region: string = '',
game: string = ''
): Promise<CombinedData[]> {
private buildEntriesQuery(enableForms: boolean, region: string, game: string) {
let query = this.supabase.from('pokedex_entries').select('*');
// Apply filters
if (!enableForms) {
// Filter to only base forms (form is NULL) OR Unown "A" (since Unown has no base form)
query = query.or('form.is.null,and(pokemon.eq.Unown,form.eq.A)');
@@ -64,17 +59,127 @@ class CombinedDataRepository {
query = query.contains('gamesToCatchIn', [game]);
}
// Always order by national dex number
// Note: Regional dex ordering would require joining with regional_dex_numbers table
query = query.order('pokedexNumber', { ascending: true });
// Stable ordering: national dex number, then base form first, then form name.
query = query
.order('pokedexNumber', { ascending: true })
.order('form', { ascending: true, nullsFirst: true });
const { data: entries, error: entriesError } = await query;
return query;
}
if (entriesError) {
console.error('Error finding combined data:', entriesError);
return [];
private async fetchEntriesByRange(
from: number,
to: number,
enableForms: boolean,
region: string,
game: string
): Promise<PokedexEntryDB[]> {
const entries: PokedexEntryDB[] = [];
let start = from;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
while (start <= to) {
const end = Math.min(to, start + maxRows - 1);
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
start,
end
);
if (error) {
console.error('Error finding paginated combined data:', error);
return [];
}
if (!data || data.length === 0) {
break;
}
entries.push(...data);
if (data.length < end - start + 1) {
break;
}
start = end + 1;
}
return entries;
}
private async fetchAllEntries(
enableForms: boolean,
region: string,
game: string
): Promise<PokedexEntryDB[]> {
const entries: PokedexEntryDB[] = [];
let start = 0;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
while (true) {
const end = start + maxRows - 1;
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
start,
end
);
if (error) {
console.error('Error finding combined data:', error);
return [];
}
if (!data || data.length === 0) {
break;
}
entries.push(...data);
if (data.length < maxRows) {
break;
}
start = end + 1;
}
return entries;
}
private async fetchCatchRecords(entryIds: number[], userId: string): Promise<CatchRecordDB[]> {
if (!this.pokedexId) return [];
if (entryIds.length === 0) return [];
const records: CatchRecordDB[] = [];
const chunkSize = 1000;
for (let i = 0; i < entryIds.length; i += chunkSize) {
const chunk = entryIds.slice(i, i + chunkSize);
const { data, error } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', userId)
.eq('pokedexId', this.pokedexId)
.in('pokemonId', chunk);
if (error) {
console.error('Error loading catch records:', error);
continue;
}
if (data) {
records.push(...data);
}
}
return records;
}
async findAllCombinedData(
userId: string,
enableForms: boolean = true,
region: string = '',
game: string = ''
): Promise<CombinedData[]> {
const entries = await this.fetchAllEntries(enableForms, region, game);
if (!entries || entries.length === 0) {
return [];
}
@@ -83,16 +188,7 @@ class CombinedDataRepository {
let catchRecords: CatchRecordDB[] = [];
if (userId && this.pokedexId) {
const entryIds = entries.map((entry) => entry.id);
const { data: records, error: recordsError } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', userId)
.eq('pokedexId', this.pokedexId)
.in('pokemonId', entryIds);
if (!recordsError && records) {
catchRecords = records;
}
catchRecords = await this.fetchCatchRecords(entryIds, userId);
}
// Combine the data exactly like master branch
@@ -125,32 +221,7 @@ class CombinedDataRepository {
const from = (page - 1) * limit;
const to = from + limit - 1;
let query = this.supabase.from('pokedex_entries').select('*').range(from, to);
// Apply filters
if (!enableForms) {
// Filter to only base forms (form is NULL) OR Unown "A" (since Unown has no base form)
query = query.or('form.is.null,and(pokemon.eq.Unown,form.eq.A)');
}
if (region) {
query = query.eq('regionToCatchIn', region);
}
if (game) {
query = query.contains('gamesToCatchIn', [game]);
}
// Always order by national dex number
// Note: Regional dex ordering would require joining with regional_dex_numbers table
query = query.order('pokedexNumber', { ascending: true });
const { data: entries, error: entriesError } = await query;
if (entriesError) {
console.error('Error finding paginated combined data:', entriesError);
return [];
}
const entries = await this.fetchEntriesByRange(from, to, enableForms, region, game);
if (!entries || entries.length === 0) {
return [];
@@ -160,16 +231,7 @@ class CombinedDataRepository {
let catchRecords: CatchRecordDB[] = [];
if (userId && this.pokedexId) {
const entryIds = entries.map((entry) => entry.id);
const { data: records, error: recordsError } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', userId)
.eq('pokedexId', this.pokedexId)
.in('pokemonId', entryIds);
if (!recordsError && records) {
catchRecords = records;
}
catchRecords = await this.fetchCatchRecords(entryIds, userId);
}
// Combine the data exactly like master branch
@@ -31,7 +31,7 @@ export const GET = async (event: RequestEvent) => {
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const catchData = await repo.findAll();
const sortedData = catchData.sort(
(a, b) => Number(a.pokedexEntryId) - Number(b.pokedexEntryId)
(a, b) => Number(a.pokemonId) - Number(b.pokemonId)
);
return json(sortedData);
} catch (err) {
@@ -99,17 +99,18 @@ export const POST = async (event: RequestEvent) => {
}
const invalidIndex = body.findIndex((r) => {
if (!r || typeof r !== 'object') return true;
const entryId = (r as Partial<CatchRecord>).pokedexEntryId;
return entryId === undefined || entryId === null || entryId === '';
const record = r as Partial<CatchRecord>;
const pokemonId = record.pokemonId;
return pokemonId === undefined || pokemonId === null || pokemonId === '';
});
if (invalidIndex !== -1) {
return json(
{ error: `Record at index ${invalidIndex} is missing required field "pokedexEntryId"` },
{ error: `Record at index ${invalidIndex} is missing required field "pokemonId"` },
{ status: 400 }
);
}
const records = body as Array<Partial<CatchRecord> & Pick<CatchRecord, 'pokedexEntryId'>>;
const records = body as Array<Partial<CatchRecord> & Pick<CatchRecord, 'pokemonId'>>;
const { session } = await event.locals.safeGetSession();
if (session) {
+4 -4
View File
@@ -99,7 +99,7 @@
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
if (!combinedData) return;
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokedexEntryId);
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
if (idx === -1) return;
// Replace the catchRecord entry with the updated version.
const current = combinedData[idx];
@@ -112,7 +112,7 @@
};
combinedData = [...combinedData.slice(0, idx), patched, ...combinedData.slice(idx + 1)];
if (selectedPokemon?.pokedexEntry._id === next.pokedexEntryId) {
if (selectedPokemon?.pokedexEntry._id === next.pokemonId) {
selectedPokemon = patched;
}
}
@@ -207,7 +207,7 @@
const baseRecord: CatchRecord = catchRecord ?? {
_id: '',
userId: localUser?.id || '',
pokedexEntryId: pokedexEntry._id,
pokemonId: pokedexEntry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
@@ -281,7 +281,7 @@
creatingRecords = true;
const newCatchRecords = pokedexEntries.map((entry: PokedexEntry) => ({
userId: localUser?.id,
pokedexEntryId: entry._id,
pokemonId: entry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
@@ -1,112 +0,0 @@
CREATE TABLE pokedex_entries (
id BIGSERIAL PRIMARY KEY,
"pokedexNumber" INTEGER NOT NULL,
pokemon TEXT NOT NULL,
form TEXT,
"canGigantamax" BOOLEAN DEFAULT FALSE,
"regionToCatchIn" TEXT,
"gamesToCatchIn" TEXT[],
"regionToEvolveIn" TEXT,
"evolutionInformation" TEXT,
"catchInformation" TEXT[],
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
-- Add unique constraint to prevent duplicate pokemon forms
ALTER TABLE pokedex_entries ADD CONSTRAINT unique_pokemon_form
UNIQUE("pokedexNumber", form);
CREATE TABLE catch_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
"pokedexEntryId" BIGINT NOT NULL REFERENCES pokedex_entries(id) ON DELETE CASCADE,
-- Catch status fields
"haveToEvolve" BOOLEAN DEFAULT FALSE,
caught BOOLEAN DEFAULT FALSE,
"inHome" BOOLEAN DEFAULT FALSE,
"hasGigantamaxed" BOOLEAN DEFAULT FALSE,
"personalNotes" TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW(),
-- Ensure one record per user per pokemon entry
UNIQUE("userId", "pokedexEntryId")
);
CREATE TABLE region_game_mappings (
id BIGSERIAL PRIMARY KEY,
region TEXT NOT NULL,
game TEXT NOT NULL,
UNIQUE(region, game)
);
CREATE TABLE metadata (
id BIGSERIAL PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
value TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for better performance
CREATE INDEX idx_pokedex_entries_pokedex_number ON pokedex_entries("pokedexNumber");
CREATE INDEX idx_pokedex_entries_pokemon ON pokedex_entries(pokemon);
CREATE INDEX idx_catch_records_user_id ON catch_records("userId");
CREATE INDEX idx_catch_records_pokedex_entry_id ON catch_records("pokedexEntryId");
CREATE INDEX idx_catch_records_caught ON catch_records(caught);
CREATE INDEX idx_catch_records_in_home ON catch_records("inHome");
CREATE INDEX idx_catch_records_user_caught ON catch_records("userId", caught);
-- Function to automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers for updated_at
CREATE TRIGGER update_pokedex_entries_updated_at
BEFORE UPDATE ON pokedex_entries
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_catch_records_updated_at
BEFORE UPDATE ON catch_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Enable RLS on all tables
ALTER TABLE pokedex_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE catch_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE region_game_mappings ENABLE ROW LEVEL SECURITY;
ALTER TABLE metadata ENABLE ROW LEVEL SECURITY;
-- RLS policies for pokedex_entries (public read)
CREATE POLICY "Anyone can view pokemon entries" ON pokedex_entries
FOR SELECT USING (true);
-- RLS policies for catch_records (user-specific)
CREATE POLICY "Users can view own catch records" ON catch_records
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert own catch records" ON catch_records
FOR INSERT WITH CHECK (auth.uid() = "userId");
CREATE POLICY "Users can update own catch records" ON catch_records
FOR UPDATE USING (auth.uid() = "userId");
CREATE POLICY "Users can delete own catch records" ON catch_records
FOR DELETE USING (auth.uid() = "userId");
-- RLS policies for region_game_mappings (public read)
CREATE POLICY "Anyone can view region game mappings" ON region_game_mappings
FOR SELECT USING (true);
-- RLS policies for metadata (public read)
CREATE POLICY "Anyone can view metadata" ON metadata
FOR SELECT USING (true);
@@ -1,51 +0,0 @@
-- Create pokedexes table
CREATE TABLE pokedexes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
-- Type flags (combinable)
"isLivingDex" BOOLEAN DEFAULT FALSE,
"isShinyDex" BOOLEAN DEFAULT FALSE,
"isOriginDex" BOOLEAN DEFAULT FALSE,
"isFormDex" BOOLEAN DEFAULT FALSE,
-- Scope (NULL = all games, or specific game)
"gameScope" TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW(),
-- User can't have duplicate pokédex names
UNIQUE("userId", name)
);
-- Create index for performance
CREATE INDEX idx_pokedexes_user_id ON pokedexes("userId");
-- Trigger to automatically update updated_at timestamp
CREATE TRIGGER update_pokedexes_updated_at
BEFORE UPDATE ON pokedexes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Enable RLS
ALTER TABLE pokedexes ENABLE ROW LEVEL SECURITY;
-- RLS POLICIES - CRITICAL: Users can ONLY access their own pokédexes
-- Users can ONLY view their own pokédexes
CREATE POLICY "Users can view own pokedexes" ON pokedexes
FOR SELECT USING (auth.uid() = "userId");
-- Users can ONLY create pokédexes for themselves
CREATE POLICY "Users can create own pokedexes" ON pokedexes
FOR INSERT WITH CHECK (auth.uid() = "userId");
-- Users can ONLY update their own pokédexes
CREATE POLICY "Users can update own pokedexes" ON pokedexes
FOR UPDATE USING (auth.uid() = "userId");
-- Users can ONLY delete their own pokédexes
CREATE POLICY "Users can delete own pokedexes" ON pokedexes
FOR DELETE USING (auth.uid() = "userId");
@@ -1,74 +0,0 @@
-- Truncate existing catch records (no real users, as confirmed)
TRUNCATE TABLE catch_records;
-- Add pokedexId column
ALTER TABLE catch_records
ADD COLUMN "pokedexId" UUID REFERENCES pokedexes(id) ON DELETE CASCADE;
-- Make pokedexId NOT NULL
ALTER TABLE catch_records
ALTER COLUMN "pokedexId" SET NOT NULL;
-- Drop old unique constraint (userId, pokedexEntryId)
ALTER TABLE catch_records
DROP CONSTRAINT IF EXISTS catch_records_userId_pokedexEntryId_key;
-- Add new unique constraint (userId, pokedexEntryId, pokedexId)
ALTER TABLE catch_records
ADD CONSTRAINT catch_records_user_pokemon_pokedex_unique
UNIQUE("userId", "pokedexEntryId", "pokedexId");
-- Add index for performance
CREATE INDEX idx_catch_records_pokedex_id ON catch_records("pokedexId");
-- RLS POLICIES - CRITICAL: Users can ONLY access catch records for their own pokédexes
-- Drop existing RLS policies if they exist
DROP POLICY IF EXISTS "Users can view own catch records" ON catch_records;
DROP POLICY IF EXISTS "Users can insert own catch records" ON catch_records;
DROP POLICY IF EXISTS "Users can update own catch records" ON catch_records;
DROP POLICY IF EXISTS "Users can delete own catch records" ON catch_records;
-- Users can ONLY view catch records for their own pokédexes
CREATE POLICY "Users can view own catch records" ON catch_records
FOR SELECT USING (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
-- Users can ONLY insert catch records for their own pokédexes
CREATE POLICY "Users can insert own catch records" ON catch_records
FOR INSERT WITH CHECK (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
-- Users can ONLY update catch records for their own pokédexes
CREATE POLICY "Users can update own catch records" ON catch_records
FOR UPDATE USING (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
-- Users can ONLY delete catch records for their own pokédexes
CREATE POLICY "Users can delete own catch records" ON catch_records
FOR DELETE USING (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
@@ -1,40 +0,0 @@
-- Create games table to store game metadata
CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
"displayName" TEXT NOT NULL,
region TEXT NOT NULL,
generation INTEGER NOT NULL,
"releaseYear" INTEGER NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE games ENABLE ROW LEVEL SECURITY;
-- Allow anyone to read games (public reference data)
CREATE POLICY "Games are publicly readable"
ON games
FOR SELECT
TO public
USING (true);
-- Insert games data from games.csv
INSERT INTO games (id, "displayName", region, generation, "releaseYear") VALUES
('red', 'Red', 'Kanto', 1, 1996),
('blue', 'Blue', 'Kanto', 1, 1996),
('yellow', 'Yellow', 'Kanto', 1, 1998),
('lg--pikachu', 'LG: Pikachu', 'Kanto', 7, 2018),
('lg--eevee', 'LG: Eevee', 'Kanto', 7, 2018),
('gold', 'Gold', 'Johto', 2, 1999),
('silver', 'Silver', 'Johto', 2, 1999),
('crystal', 'Crystal', 'Johto', 2, 2000),
('heartgold', 'HeartGold', 'Johto', 4, 2009),
('soulsilver', 'SoulSilver', 'Johto', 4, 2009),
('legends-arceus', 'Legends: Arceus', 'Hisui', 8, 2022)
ON CONFLICT (id) DO UPDATE SET
"displayName" = EXCLUDED."displayName",
region = EXCLUDED.region,
generation = EXCLUDED.generation,
"releaseYear" = EXCLUDED."releaseYear",
"updatedAt" = NOW();
@@ -1,22 +0,0 @@
-- Create stats_cache table to store cached statistics
-- This table will be updated once per day to improve performance
CREATE TABLE stats_cache (
id BIGSERIAL PRIMARY KEY,
pokemon_caught BIGINT NOT NULL DEFAULT 0,
total_users BIGINT NOT NULL DEFAULT 0,
completed_pokedexes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create index for faster lookups
CREATE INDEX idx_stats_cache_updated_at ON stats_cache(updated_at DESC);
-- Enable RLS
ALTER TABLE stats_cache ENABLE ROW LEVEL SECURITY;
-- RLS policies: Only allow reads (stats are public)
CREATE POLICY "Anyone can view stats cache" ON stats_cache
FOR SELECT USING (true);
-- No insert/update/delete policies - only database functions can modify this table
@@ -1,212 +0,0 @@
-- Normalize reference data schema to match data/csvs/*.csv
--
-- Target tables (reference data):
-- - regions
-- - games (add regionId FK, keep existing columns)
-- - pokemon (1 row per pokedexNumber + form, matching data/csvs/pokemon.csv)
-- - pokemon_origin_games (join table)
-- - region_game_mappings (join table)
--
-- Also migrates user data table catch_records to reference pokemon instead of pokedex_entries.
-- 1) Drop tables that are being replaced
--
-- NOTE: We intentionally keep auth.users and user-owned pokedexes.
-- Any existing catch_records will be wiped because the foreign key target changes.
TRUNCATE TABLE catch_records;
-- Drop old mapping table (depends on pokedex_entries)
DROP TABLE IF EXISTS pokedex_entries_mapping;
-- Drop old regional dex table (per approved plan)
DROP TABLE IF EXISTS regional_dex_numbers;
-- Drop old region-game mappings (string-based)
DROP TABLE IF EXISTS region_game_mappings;
-- Drop old pokedex entries table (legacy, replaced by pokemon)
DROP TABLE IF EXISTS pokedex_entries;
-- 2) Regions
CREATE TABLE IF NOT EXISTS regions (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE regions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Regions are publicly readable" ON regions
FOR SELECT TO public USING (true);
CREATE TRIGGER update_regions_updated_at
BEFORE UPDATE ON regions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 3) Games: keep existing id + displayName, add regionId FK
ALTER TABLE games
ADD COLUMN IF NOT EXISTS "regionId" BIGINT;
ALTER TABLE games
ADD CONSTRAINT games_region_id_fkey
FOREIGN KEY ("regionId") REFERENCES regions(id)
ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_games_region_id ON games("regionId");
-- 4) Region ↔ Game mapping (normalized)
CREATE TABLE IF NOT EXISTS region_game_mappings (
"regionId" BIGINT NOT NULL REFERENCES regions(id) ON DELETE CASCADE,
"gameId" TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
PRIMARY KEY ("regionId", "gameId")
);
ALTER TABLE region_game_mappings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Region-game mappings are publicly readable" ON region_game_mappings
FOR SELECT TO public USING (true);
CREATE INDEX IF NOT EXISTS idx_region_game_mappings_game_id ON region_game_mappings("gameId");
-- 5) Pokemon (1 row per pokedexNumber + form)
CREATE TABLE IF NOT EXISTS pokemon (
id BIGSERIAL PRIMARY KEY,
"pokedexNumber" INTEGER NOT NULL,
pokemon TEXT NOT NULL,
form TEXT,
"originRegionId" BIGINT NOT NULL REFERENCES regions(id) ON DELETE RESTRICT,
"canGigantamax" BOOLEAN DEFAULT FALSE,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
-- Uniqueness: exactly one "base" (form IS NULL) per pokedex number
CREATE UNIQUE INDEX IF NOT EXISTS uniq_pokemon_base_form
ON pokemon("pokedexNumber")
WHERE form IS NULL;
-- Uniqueness: for non-null forms, unique per (pokedexNumber, form)
CREATE UNIQUE INDEX IF NOT EXISTS uniq_pokemon_number_form
ON pokemon("pokedexNumber", form)
WHERE form IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pokemon_pokedex_number ON pokemon("pokedexNumber");
CREATE INDEX IF NOT EXISTS idx_pokemon_name ON pokemon(pokemon);
CREATE INDEX IF NOT EXISTS idx_pokemon_origin_region_id ON pokemon("originRegionId");
ALTER TABLE pokemon ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Pokemon are publicly readable" ON pokemon
FOR SELECT TO public USING (true);
CREATE TRIGGER update_pokemon_updated_at
BEFORE UPDATE ON pokemon
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 6) Pokemon ↔ origin games (normalized)
CREATE TABLE IF NOT EXISTS pokemon_origin_games (
"pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
"gameId" TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
PRIMARY KEY ("pokemonId", "gameId")
);
ALTER TABLE pokemon_origin_games ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Pokemon origin games are publicly readable" ON pokemon_origin_games
FOR SELECT TO public USING (true);
CREATE INDEX IF NOT EXISTS idx_pokemon_origin_games_game_id ON pokemon_origin_games("gameId");
-- 6.1) Compatibility view: keep the app working while refactoring
--
-- This view matches the old pokedex_entries shape used by the app:
-- - id (BIGINT)
-- - "pokedexNumber"
-- - pokemon
-- - form
-- - "canGigantamax"
-- - "regionToCatchIn" (region name)
-- - "gamesToCatchIn" (TEXT[] of game display names)
-- - "createdAt" / "updatedAt"
--
-- Note: read-only. Inserts/updates should target the new tables.
CREATE OR REPLACE VIEW pokedex_entries AS
SELECT
p.id,
p."pokedexNumber",
p.pokemon,
p.form,
p."canGigantamax",
r.name AS "regionToCatchIn",
COALESCE(
ARRAY_AGG(g."displayName" ORDER BY g."displayName") FILTER (WHERE g.id IS NOT NULL),
ARRAY[]::TEXT[]
) AS "gamesToCatchIn",
p."createdAt",
p."updatedAt"
FROM pokemon p
JOIN regions r ON r.id = p."originRegionId"
LEFT JOIN pokemon_origin_games pog ON pog."pokemonId" = p.id
LEFT JOIN games g ON g.id = pog."gameId"
GROUP BY p.id, r.name;
-- RLS: views respect underlying table policies; keep it explicitly public readable
GRANT SELECT ON pokedex_entries TO anon, authenticated;
-- 7) Recreate pokedex mapping table for expected entries
-- (same intent as old pokedex_entries_mapping, but points at pokemon.id)
CREATE TABLE IF NOT EXISTS pokedex_pokemon_mapping (
id BIGSERIAL PRIMARY KEY,
"pokedexId" UUID NOT NULL REFERENCES pokedexes(id) ON DELETE CASCADE,
"pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
UNIQUE("pokedexId", "pokemonId")
);
CREATE INDEX IF NOT EXISTS idx_pokedex_pokemon_mapping_pokedex_id
ON pokedex_pokemon_mapping("pokedexId");
CREATE INDEX IF NOT EXISTS idx_pokedex_pokemon_mapping_pokemon_id
ON pokedex_pokemon_mapping("pokemonId");
ALTER TABLE pokedex_pokemon_mapping ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view pokedex pokemon mapping"
ON pokedex_pokemon_mapping
FOR SELECT USING (true);
CREATE POLICY "Users can insert own pokedex pokemon mappings"
ON pokedex_pokemon_mapping
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = pokedex_pokemon_mapping."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
CREATE POLICY "Users can delete own pokedex pokemon mappings"
ON pokedex_pokemon_mapping
FOR DELETE USING (
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = pokedex_pokemon_mapping."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
-- 8) Update catch_records to reference pokemon instead of pokedex_entries
ALTER TABLE catch_records
DROP COLUMN IF EXISTS "pokedexEntryId";
ALTER TABLE catch_records
ADD COLUMN IF NOT EXISTS "pokemonId" BIGINT REFERENCES pokemon(id) ON DELETE CASCADE;
ALTER TABLE catch_records
ALTER COLUMN "pokemonId" SET NOT NULL;
-- Drop old unique constraint (name may vary across environments)
ALTER TABLE catch_records
DROP CONSTRAINT IF EXISTS catch_records_user_pokemon_pokedex_unique;
ALTER TABLE catch_records
ADD CONSTRAINT catch_records_user_pokemon_pokedex_unique
UNIQUE("userId", "pokemonId", "pokedexId");
CREATE INDEX IF NOT EXISTS idx_catch_records_pokemon_id ON catch_records("pokemonId");
File diff suppressed because it is too large Load Diff
@@ -1,137 +0,0 @@
-- Seed regions + games + region_game_mappings from CSVs
-- Auto-generated from data/csvs/regions.csv and data/csvs/games.csv
-- Regions
INSERT INTO regions (name) VALUES
('Kanto'),
('Johto'),
('Hoenn'),
('Sinnoh'),
('Unova'),
('Galar'),
('Alola'),
('Kalos'),
('Hisui'),
('Paldea'),
('Go'),
('Home'),
('Orre'),
('Ranch')
ON CONFLICT (name) DO UPDATE SET
"updatedAt" = NOW();
-- Games
INSERT INTO games (id, "displayName", region, generation, "releaseYear", "regionId") VALUES
('red', 'Red', 'Kanto', 1, 1996, (SELECT id FROM regions WHERE name = 'Kanto')),
('blue', 'Blue', 'Kanto', 1, 1996, (SELECT id FROM regions WHERE name = 'Kanto')),
('yellow', 'Yellow', 'Kanto', 1, 1998, (SELECT id FROM regions WHERE name = 'Kanto')),
('gold', 'Gold', 'Johto', 2, 1999, (SELECT id FROM regions WHERE name = 'Johto')),
('silver', 'Silver', 'Johto', 2, 1999, (SELECT id FROM regions WHERE name = 'Johto')),
('crystal', 'Crystal', 'Johto', 2, 2000, (SELECT id FROM regions WHERE name = 'Johto')),
('ruby', 'Ruby', 'Hoenn', 3, 2002, (SELECT id FROM regions WHERE name = 'Hoenn')),
('sapphire', 'Sapphire', 'Hoenn', 3, 2002, (SELECT id FROM regions WHERE name = 'Hoenn')),
('colosseum', 'Colosseum', 'Orre', 3, 2003, (SELECT id FROM regions WHERE name = 'Orre')),
('emerald', 'Emerald', 'Hoenn', 3, 2004, (SELECT id FROM regions WHERE name = 'Hoenn')),
('firered', 'FireRed', 'Kanto', 1, 2004, (SELECT id FROM regions WHERE name = 'Kanto')),
('leafgreen', 'LeafGreen', 'Kanto', 1, 2004, (SELECT id FROM regions WHERE name = 'Kanto')),
('xd-gale-of-darkness', 'XD: Gale of Darkness', 'Orre', 3, 2005, (SELECT id FROM regions WHERE name = 'Orre')),
('diamond', 'Diamond', 'Sinnoh', 4, 2006, (SELECT id FROM regions WHERE name = 'Sinnoh')),
('pearl', 'Pearl', 'Sinnoh', 4, 2006, (SELECT id FROM regions WHERE name = 'Sinnoh')),
('platinum', 'Platinum', 'Sinnoh', 4, 2008, (SELECT id FROM regions WHERE name = 'Sinnoh')),
('ranch', 'Ranch', 'Ranch', 4, 2008, (SELECT id FROM regions WHERE name = 'Ranch')),
('heart-gold', 'Heart Gold', 'Johto', 2, 2009, (SELECT id FROM regions WHERE name = 'Johto')),
('soul-silver', 'Soul Silver', 'Johto', 2, 2009, (SELECT id FROM regions WHERE name = 'Johto')),
('black', 'Black', 'Unova', 5, 2010, (SELECT id FROM regions WHERE name = 'Unova')),
('white', 'White', 'Unova', 5, 2010, (SELECT id FROM regions WHERE name = 'Unova')),
('black-2', 'Black 2', 'Unova', 5, 2012, (SELECT id FROM regions WHERE name = 'Unova')),
('white-2', 'White 2', 'Unova', 5, 2012, (SELECT id FROM regions WHERE name = 'Unova')),
('dream-radar', 'Dream Radar', 'Unova', 5, 2012, (SELECT id FROM regions WHERE name = 'Unova')),
('x', 'X', 'Kalos', 6, 2013, (SELECT id FROM regions WHERE name = 'Kalos')),
('y', 'Y', 'Kalos', 6, 2013, (SELECT id FROM regions WHERE name = 'Kalos')),
('omega-ruby', 'Omega Ruby', 'Hoenn', 3, 2014, (SELECT id FROM regions WHERE name = 'Hoenn')),
('alpha-sapphire', 'Alpha Sapphire', 'Hoenn', 3, 2014, (SELECT id FROM regions WHERE name = 'Hoenn')),
('sun', 'Sun', 'Alola', 7, 2016, (SELECT id FROM regions WHERE name = 'Alola')),
('moon', 'Moon', 'Alola', 7, 2016, (SELECT id FROM regions WHERE name = 'Alola')),
('moon-(demo)', 'Moon (demo)', 'Alola', 7, 2016, (SELECT id FROM regions WHERE name = 'Alola')),
('go', 'Go', 'Go', 7, 2016, (SELECT id FROM regions WHERE name = 'Go')),
('ultra-sun', 'Ultra Sun', 'Alola', 7, 2017, (SELECT id FROM regions WHERE name = 'Alola')),
('ultra-moon', 'Ultra Moon', 'Alola', 7, 2017, (SELECT id FROM regions WHERE name = 'Alola')),
('lg-pikachu', 'LG: Pikachu', 'Kanto', 1, 2018, (SELECT id FROM regions WHERE name = 'Kanto')),
('lg-eevee', 'LG: Eevee', 'Kanto', 1, 2018, (SELECT id FROM regions WHERE name = 'Kanto')),
('sword', 'Sword', 'Galar', 8, 2019, (SELECT id FROM regions WHERE name = 'Galar')),
('shield', 'Shield', 'Galar', 8, 2019, (SELECT id FROM regions WHERE name = 'Galar')),
('home', 'Home', 'Home', 8, 2020, (SELECT id FROM regions WHERE name = 'Home')),
('brilliant-diamond', 'Brilliant Diamond', 'Sinnoh', 4, 2021, (SELECT id FROM regions WHERE name = 'Sinnoh')),
('shining-pearl', 'Shining Pearl', 'Sinnoh', 4, 2021, (SELECT id FROM regions WHERE name = 'Sinnoh')),
('legends-arceus', 'Legends: Arceus', 'Hisui', 8, 2022, (SELECT id FROM regions WHERE name = 'Hisui')),
('scarlet', 'Scarlet', 'Paldea', 9, 2022, (SELECT id FROM regions WHERE name = 'Paldea')),
('violet', 'Violet', 'Paldea', 9, 2022, (SELECT id FROM regions WHERE name = 'Paldea')),
('legends-z-a', 'Legends: Z-A', 'Kalos', 6, 2025, (SELECT id FROM regions WHERE name = 'Kalos'))
ON CONFLICT (id) DO UPDATE SET
"displayName" = EXCLUDED."displayName",
region = EXCLUDED.region,
generation = EXCLUDED.generation,
"releaseYear" = EXCLUDED."releaseYear",
"regionId" = EXCLUDED."regionId",
"updatedAt" = NOW();
-- Region ↔ game mappings
INSERT INTO region_game_mappings ("regionId", "gameId") VALUES
((SELECT id FROM regions WHERE name = 'Kanto'), 'red'),
((SELECT id FROM regions WHERE name = 'Kanto'), 'blue'),
((SELECT id FROM regions WHERE name = 'Kanto'), 'yellow'),
((SELECT id FROM regions WHERE name = 'Johto'), 'gold'),
((SELECT id FROM regions WHERE name = 'Johto'), 'silver'),
((SELECT id FROM regions WHERE name = 'Johto'), 'crystal'),
((SELECT id FROM regions WHERE name = 'Hoenn'), 'ruby'),
((SELECT id FROM regions WHERE name = 'Hoenn'), 'sapphire'),
((SELECT id FROM regions WHERE name = 'Orre'), 'colosseum'),
((SELECT id FROM regions WHERE name = 'Hoenn'), 'emerald'),
((SELECT id FROM regions WHERE name = 'Kanto'), 'firered'),
((SELECT id FROM regions WHERE name = 'Kanto'), 'leafgreen'),
((SELECT id FROM regions WHERE name = 'Orre'), 'xd-gale-of-darkness'),
((SELECT id FROM regions WHERE name = 'Sinnoh'), 'diamond'),
((SELECT id FROM regions WHERE name = 'Sinnoh'), 'pearl'),
((SELECT id FROM regions WHERE name = 'Sinnoh'), 'platinum'),
((SELECT id FROM regions WHERE name = 'Ranch'), 'ranch'),
((SELECT id FROM regions WHERE name = 'Johto'), 'heart-gold'),
((SELECT id FROM regions WHERE name = 'Johto'), 'soul-silver'),
((SELECT id FROM regions WHERE name = 'Unova'), 'black'),
((SELECT id FROM regions WHERE name = 'Unova'), 'white'),
((SELECT id FROM regions WHERE name = 'Unova'), 'black-2'),
((SELECT id FROM regions WHERE name = 'Unova'), 'white-2'),
((SELECT id FROM regions WHERE name = 'Unova'), 'dream-radar'),
((SELECT id FROM regions WHERE name = 'Kalos'), 'x'),
((SELECT id FROM regions WHERE name = 'Kalos'), 'y'),
((SELECT id FROM regions WHERE name = 'Hoenn'), 'omega-ruby'),
((SELECT id FROM regions WHERE name = 'Hoenn'), 'alpha-sapphire'),
((SELECT id FROM regions WHERE name = 'Alola'), 'sun'),
((SELECT id FROM regions WHERE name = 'Alola'), 'moon'),
((SELECT id FROM regions WHERE name = 'Alola'), 'moon-(demo)'),
((SELECT id FROM regions WHERE name = 'Go'), 'go'),
((SELECT id FROM regions WHERE name = 'Alola'), 'ultra-sun'),
((SELECT id FROM regions WHERE name = 'Alola'), 'ultra-moon'),
((SELECT id FROM regions WHERE name = 'Kanto'), 'lg-pikachu'),
((SELECT id FROM regions WHERE name = 'Kanto'), 'lg-eevee'),
((SELECT id FROM regions WHERE name = 'Galar'), 'sword'),
((SELECT id FROM regions WHERE name = 'Galar'), 'shield'),
((SELECT id FROM regions WHERE name = 'Home'), 'home'),
((SELECT id FROM regions WHERE name = 'Sinnoh'), 'brilliant-diamond'),
((SELECT id FROM regions WHERE name = 'Sinnoh'), 'shining-pearl'),
((SELECT id FROM regions WHERE name = 'Hisui'), 'legends-arceus'),
((SELECT id FROM regions WHERE name = 'Paldea'), 'scarlet'),
((SELECT id FROM regions WHERE name = 'Paldea'), 'violet'),
((SELECT id FROM regions WHERE name = 'Kalos'), 'legends-z-a')
ON CONFLICT ("regionId", "gameId") DO NOTHING;
-- Add metadata
INSERT INTO metadata (key, value) VALUES
('regions_seeded', 'true'),
('regions_seed_date', NOW()::TEXT),
('regions_count', '14'),
('games_seeded', 'true'),
('games_seed_date', NOW()::TEXT),
('games_count', '45')
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
"updatedAt" = NOW();
@@ -1,129 +0,0 @@
-- Update stats + mapping functions to use the new normalized schema.
--
-- After [`supabase/migrations/20260116210000_normalize_csv_schema.sql`](supabase/migrations/20260116210000_normalize_csv_schema.sql:1):
-- - catch_records references "pokemonId" (not "pokedexEntryId")
-- - explicit expected entries table is pokedex_pokemon_mapping (not pokedex_entries_mapping)
-- 1) Recalculate function: same name/signature, but targets pokedex_pokemon_mapping.
CREATE OR REPLACE FUNCTION recalculate_pokedex_mappings(
p_pokedex_id UUID,
p_entry_ids BIGINT[]
)
RETURNS VOID
SECURITY DEFINER
SET search_path = public, pg_temp
LANGUAGE plpgsql
AS $$
DECLARE
v_user_id UUID;
BEGIN
IF p_entry_ids IS NULL OR array_length(p_entry_ids, 1) IS NULL THEN
RAISE EXCEPTION 'entry_ids cannot be null or empty';
END IF;
SELECT "userId" INTO v_user_id
FROM pokedexes
WHERE id = p_pokedex_id;
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'pokedex not found';
END IF;
IF auth.uid() IS NULL OR v_user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'not authorized: you do not own this pokedex';
END IF;
DELETE FROM pokedex_pokemon_mapping
WHERE "pokedexId" = p_pokedex_id;
INSERT INTO pokedex_pokemon_mapping ("pokedexId", "pokemonId")
SELECT p_pokedex_id, unnest(p_entry_ids)
ON CONFLICT ("pokedexId", "pokemonId") DO NOTHING;
END;
$$;
-- 2) Stats functions: completed pokedexes are those where all expected pokemon are caught.
CREATE OR REPLACE FUNCTION update_and_get_stats()
RETURNS TABLE (
pokemon_caught BIGINT,
total_users BIGINT,
completed_pokedexes BIGINT,
updated_at TIMESTAMPTZ
)
SECURITY DEFINER
SET search_path = public, pg_temp
LANGUAGE plpgsql
AS $$
DECLARE
v_pokemon_caught BIGINT;
v_total_users BIGINT;
v_completed_pokedexes BIGINT;
v_updated_at TIMESTAMPTZ := NOW();
BEGIN
SELECT COUNT(*) INTO v_pokemon_caught
FROM catch_records
WHERE caught = true;
SELECT COUNT(*) INTO v_total_users
FROM auth.users;
SELECT COUNT(*) INTO v_completed_pokedexes
FROM (
SELECT cr."pokedexId"
FROM catch_records cr
INNER JOIN pokedex_pokemon_mapping ppm
ON cr."pokedexId" = ppm."pokedexId"
AND cr."pokemonId" = ppm."pokemonId"
GROUP BY cr."pokedexId"
HAVING COUNT(*) = COUNT(*) FILTER (WHERE cr.caught = true)
AND COUNT(*) = (
SELECT COUNT(*)
FROM pokedex_pokemon_mapping
WHERE "pokedexId" = cr."pokedexId"
)
) completed;
INSERT INTO stats_cache (id, pokemon_caught, total_users, completed_pokedexes, updated_at)
VALUES (1, v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at)
ON CONFLICT (id) DO UPDATE
SET pokemon_caught = EXCLUDED.pokemon_caught,
total_users = EXCLUDED.total_users,
completed_pokedexes = EXCLUDED.completed_pokedexes,
updated_at = v_updated_at;
RETURN QUERY
SELECT v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at AS updated_at;
END;
$$;
CREATE OR REPLACE FUNCTION get_public_stats()
RETURNS TABLE (
pokemon_caught BIGINT,
total_users BIGINT,
completed_pokedexes BIGINT,
updated_at TIMESTAMPTZ
)
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
DECLARE
v_cache_exists BOOLEAN;
BEGIN
SELECT EXISTS(
SELECT 1 FROM stats_cache
WHERE stats_cache.updated_at > NOW() - INTERVAL '24 hours'
) INTO v_cache_exists;
IF v_cache_exists THEN
RETURN QUERY
SELECT sc.pokemon_caught, sc.total_users, sc.completed_pokedexes, sc.updated_at
FROM stats_cache sc
ORDER BY sc.updated_at DESC
LIMIT 1;
ELSE
RETURN QUERY
SELECT * FROM update_and_get_stats();
END IF;
END;
$$;
@@ -0,0 +1,424 @@
-- Baseline schema for reference data + user data.
-- Replaces legacy migrations; safe to reapply on a fresh database.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Function to automatically update updatedAt timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Reference data: regions
CREATE TABLE regions (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
-- Reference data: games
CREATE TABLE games (
id TEXT PRIMARY KEY,
"displayName" TEXT NOT NULL UNIQUE,
"regionId" BIGINT NOT NULL REFERENCES regions(id) ON DELETE RESTRICT,
region TEXT NOT NULL,
"releaseYear" INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_games_region_id ON games("regionId");
CREATE INDEX idx_games_release_year ON games("releaseYear");
-- Reference data: pokemon (one row per pokedexNumber + form)
CREATE TABLE pokemon (
id BIGSERIAL PRIMARY KEY,
"pokedexNumber" INTEGER NOT NULL,
pokemon TEXT NOT NULL,
form TEXT,
"originRegionId" BIGINT NOT NULL REFERENCES regions(id) ON DELETE RESTRICT,
"canGigantamax" BOOLEAN DEFAULT FALSE,
"regionToEvolveIn" TEXT,
"evolutionInformation" TEXT,
"catchInformation" TEXT[],
notes TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX uniq_pokemon_base_form
ON pokemon("pokedexNumber")
WHERE form IS NULL;
CREATE UNIQUE INDEX uniq_pokemon_number_form
ON pokemon("pokedexNumber", form)
WHERE form IS NOT NULL;
CREATE INDEX idx_pokemon_pokedex_number ON pokemon("pokedexNumber");
CREATE INDEX idx_pokemon_name ON pokemon(pokemon);
CREATE INDEX idx_pokemon_origin_region_id ON pokemon("originRegionId");
-- Reference data: pokemon origin games (many-to-many)
CREATE TABLE pokemon_origin_games (
"pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
"gameId" TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
PRIMARY KEY ("pokemonId", "gameId")
);
CREATE INDEX idx_pokemon_origin_games_game_id ON pokemon_origin_games("gameId");
-- Reference data: per-game pokedex entries (seeded from per-game CSVs)
CREATE TABLE game_pokedex_entries (
"gameId" TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
"pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
"dexNumber" INTEGER,
notes TEXT,
PRIMARY KEY ("gameId", "pokemonId")
);
CREATE INDEX idx_game_pokedex_entries_game_id ON game_pokedex_entries("gameId");
CREATE INDEX idx_game_pokedex_entries_pokemon_id ON game_pokedex_entries("pokemonId");
-- User-owned pokedexes
CREATE TABLE pokedexes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
-- Type flags (combinable)
"isLivingDex" BOOLEAN DEFAULT FALSE,
"isShinyDex" BOOLEAN DEFAULT FALSE,
"isOriginDex" BOOLEAN DEFAULT FALSE,
"isFormDex" BOOLEAN DEFAULT FALSE,
-- Scope (NULL = all games, or specific game displayName)
"gameScope" TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW(),
UNIQUE("userId", name)
);
CREATE INDEX idx_pokedexes_user_id ON pokedexes("userId");
-- User-owned expected entries per pokedex
CREATE TABLE pokedex_pokemon_mapping (
id BIGSERIAL PRIMARY KEY,
"pokedexId" UUID NOT NULL REFERENCES pokedexes(id) ON DELETE CASCADE,
"pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
UNIQUE("pokedexId", "pokemonId")
);
CREATE INDEX idx_pokedex_pokemon_mapping_pokedex_id
ON pokedex_pokemon_mapping("pokedexId");
CREATE INDEX idx_pokedex_pokemon_mapping_pokemon_id
ON pokedex_pokemon_mapping("pokemonId");
-- User-owned catch records
CREATE TABLE catch_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
"pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
"pokedexId" UUID NOT NULL REFERENCES pokedexes(id) ON DELETE CASCADE,
-- Catch status fields
"haveToEvolve" BOOLEAN DEFAULT FALSE,
caught BOOLEAN DEFAULT FALSE,
"inHome" BOOLEAN DEFAULT FALSE,
"hasGigantamaxed" BOOLEAN DEFAULT FALSE,
"personalNotes" TEXT,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW(),
UNIQUE("userId", "pokemonId", "pokedexId")
);
CREATE INDEX idx_catch_records_user_id ON catch_records("userId");
CREATE INDEX idx_catch_records_pokemon_id ON catch_records("pokemonId");
CREATE INDEX idx_catch_records_pokedex_id ON catch_records("pokedexId");
CREATE INDEX idx_catch_records_caught ON catch_records(caught);
CREATE INDEX idx_catch_records_user_caught ON catch_records("userId", caught);
-- Cached public stats
CREATE TABLE stats_cache (
id BIGSERIAL PRIMARY KEY,
pokemon_caught BIGINT NOT NULL DEFAULT 0,
total_users BIGINT NOT NULL DEFAULT 0,
completed_pokedexes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_stats_cache_updated_at ON stats_cache(updated_at DESC);
-- Triggers for updatedAt
CREATE TRIGGER update_regions_updated_at
BEFORE UPDATE ON regions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_games_updated_at
BEFORE UPDATE ON games
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_pokemon_updated_at
BEFORE UPDATE ON pokemon
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_pokedexes_updated_at
BEFORE UPDATE ON pokedexes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_catch_records_updated_at
BEFORE UPDATE ON catch_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- RLS policies
ALTER TABLE regions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Regions are publicly readable" ON regions
FOR SELECT TO public USING (true);
ALTER TABLE games ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Games are publicly readable" ON games
FOR SELECT TO public USING (true);
ALTER TABLE pokemon ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Pokemon are publicly readable" ON pokemon
FOR SELECT TO public USING (true);
ALTER TABLE pokemon_origin_games ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Pokemon origin games are publicly readable" ON pokemon_origin_games
FOR SELECT TO public USING (true);
ALTER TABLE game_pokedex_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Game pokedex entries are publicly readable" ON game_pokedex_entries
FOR SELECT TO public USING (true);
ALTER TABLE pokedexes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own pokedexes" ON pokedexes
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can create own pokedexes" ON pokedexes
FOR INSERT WITH CHECK (auth.uid() = "userId");
CREATE POLICY "Users can update own pokedexes" ON pokedexes
FOR UPDATE USING (auth.uid() = "userId");
CREATE POLICY "Users can delete own pokedexes" ON pokedexes
FOR DELETE USING (auth.uid() = "userId");
ALTER TABLE pokedex_pokemon_mapping ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view pokedex pokemon mapping"
ON pokedex_pokemon_mapping
FOR SELECT USING (true);
CREATE POLICY "Users can insert own pokedex pokemon mappings"
ON pokedex_pokemon_mapping
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = pokedex_pokemon_mapping."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
CREATE POLICY "Users can delete own pokedex pokemon mappings"
ON pokedex_pokemon_mapping
FOR DELETE USING (
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = pokedex_pokemon_mapping."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
ALTER TABLE catch_records ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own catch records" ON catch_records
FOR SELECT USING (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
CREATE POLICY "Users can insert own catch records" ON catch_records
FOR INSERT WITH CHECK (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
CREATE POLICY "Users can update own catch records" ON catch_records
FOR UPDATE USING (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
CREATE POLICY "Users can delete own catch records" ON catch_records
FOR DELETE USING (
auth.uid() = "userId" AND
EXISTS (
SELECT 1 FROM pokedexes
WHERE pokedexes.id = catch_records."pokedexId"
AND pokedexes."userId" = auth.uid()
)
);
ALTER TABLE stats_cache ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view stats cache" ON stats_cache
FOR SELECT USING (true);
-- Compatibility view for the app (pokedex_entries)
CREATE OR REPLACE VIEW pokedex_entries AS
SELECT
p.id,
p."pokedexNumber",
p.pokemon,
p.form,
p."canGigantamax",
r.name AS "regionToCatchIn",
COALESCE(
ARRAY_AGG(g."displayName" ORDER BY g."displayName") FILTER (WHERE g.id IS NOT NULL),
ARRAY[]::TEXT[]
) AS "gamesToCatchIn",
p."regionToEvolveIn",
p."evolutionInformation",
p."catchInformation",
p."createdAt",
p."updatedAt"
FROM pokemon p
JOIN regions r ON r.id = p."originRegionId"
LEFT JOIN pokemon_origin_games pog ON pog."pokemonId" = p.id
LEFT JOIN games g ON g.id = pog."gameId"
GROUP BY p.id, r.name;
GRANT SELECT ON pokedex_entries TO anon, authenticated;
-- RPC: recalculate expected mappings for a pokedex
CREATE OR REPLACE FUNCTION recalculate_pokedex_mappings(
p_pokedex_id UUID,
p_entry_ids BIGINT[]
)
RETURNS VOID
SECURITY DEFINER
SET search_path = public, pg_temp
LANGUAGE plpgsql
AS $$
DECLARE
v_user_id UUID;
BEGIN
IF p_entry_ids IS NULL OR array_length(p_entry_ids, 1) IS NULL THEN
RAISE EXCEPTION 'entry_ids cannot be null or empty';
END IF;
SELECT "userId" INTO v_user_id
FROM pokedexes
WHERE id = p_pokedex_id;
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'pokedex not found';
END IF;
IF auth.uid() IS NULL OR v_user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'not authorized: you do not own this pokedex';
END IF;
DELETE FROM pokedex_pokemon_mapping
WHERE "pokedexId" = p_pokedex_id;
INSERT INTO pokedex_pokemon_mapping ("pokedexId", "pokemonId")
SELECT p_pokedex_id, unnest(p_entry_ids)
ON CONFLICT ("pokedexId", "pokemonId") DO NOTHING;
END;
$$;
-- RPC: stats cache
CREATE OR REPLACE FUNCTION update_and_get_stats()
RETURNS TABLE (
pokemon_caught BIGINT,
total_users BIGINT,
completed_pokedexes BIGINT,
updated_at TIMESTAMPTZ
)
SECURITY DEFINER
SET search_path = public, pg_temp
LANGUAGE plpgsql
AS $$
DECLARE
v_pokemon_caught BIGINT;
v_total_users BIGINT;
v_completed_pokedexes BIGINT;
v_updated_at TIMESTAMPTZ := NOW();
BEGIN
SELECT COUNT(*) INTO v_pokemon_caught
FROM catch_records
WHERE caught = true;
SELECT COUNT(*) INTO v_total_users
FROM auth.users;
SELECT COUNT(*) INTO v_completed_pokedexes
FROM (
SELECT cr."pokedexId"
FROM catch_records cr
INNER JOIN pokedex_pokemon_mapping ppm
ON cr."pokedexId" = ppm."pokedexId"
AND cr."pokemonId" = ppm."pokemonId"
GROUP BY cr."pokedexId"
HAVING COUNT(*) = COUNT(*) FILTER (WHERE cr.caught = true)
AND COUNT(*) = (
SELECT COUNT(*)
FROM pokedex_pokemon_mapping
WHERE "pokedexId" = cr."pokedexId"
)
) completed;
INSERT INTO stats_cache (id, pokemon_caught, total_users, completed_pokedexes, updated_at)
VALUES (1, v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at)
ON CONFLICT (id) DO UPDATE
SET pokemon_caught = EXCLUDED.pokemon_caught,
total_users = EXCLUDED.total_users,
completed_pokedexes = EXCLUDED.completed_pokedexes,
updated_at = v_updated_at;
RETURN QUERY
SELECT v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at AS updated_at;
END;
$$;
CREATE OR REPLACE FUNCTION get_public_stats()
RETURNS TABLE (
pokemon_caught BIGINT,
total_users BIGINT,
completed_pokedexes BIGINT,
updated_at TIMESTAMPTZ
)
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
DECLARE
v_cache_exists BOOLEAN;
BEGIN
SELECT EXISTS(
SELECT 1 FROM stats_cache
WHERE stats_cache.updated_at > NOW() - INTERVAL '24 hours'
) INTO v_cache_exists;
IF v_cache_exists THEN
RETURN QUERY
SELECT sc.pokemon_caught, sc.total_users, sc.completed_pokedexes, sc.updated_at
FROM stats_cache sc
ORDER BY sc.updated_at DESC
LIMIT 1;
ELSE
RETURN QUERY
SELECT * FROM update_and_get_stats();
END IF;
END;
$$;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ function mkRecord(overrides: Partial<CatchRecord> = {}): CatchRecord {
_id: '',
userId: 'u1',
pokedexId: 'p1',
pokedexEntryId: '25',
pokemonId: '25',
haveToEvolve: false,
caught: false,
inHome: false,