-- 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, "releaseOrder" INTEGER NOT NULL, "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: game dexes (base, DLCs, islands, etc.) CREATE TABLE game_dexes ( id TEXT PRIMARY KEY, "gameId" TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, "displayName" TEXT NOT NULL, "sortOrder" INTEGER NOT NULL, "isDlc" BOOLEAN DEFAULT FALSE, "parentDexId" TEXT REFERENCES game_dexes(id) ON DELETE SET NULL, "createdAt" TIMESTAMPTZ DEFAULT NOW(), "updatedAt" TIMESTAMPTZ DEFAULT NOW(), UNIQUE ("gameId", "displayName") ); CREATE INDEX idx_game_dexes_game_id ON game_dexes("gameId"); CREATE INDEX idx_game_dexes_sort_order ON game_dexes("sortOrder"); -- 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, "spriteKey" 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 ( "dexId" TEXT NOT NULL REFERENCES game_dexes(id) ON DELETE CASCADE, "pokemonId" BIGINT NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, "dexNumber" INTEGER, notes TEXT, PRIMARY KEY ("dexId", "pokemonId") ); CREATE INDEX idx_game_pokedex_entries_dex_id ON game_pokedex_entries("dexId"); 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"); -- Selected dex scopes per user pokedex CREATE TABLE pokedex_dex_scopes ( "pokedexId" UUID NOT NULL REFERENCES pokedexes(id) ON DELETE CASCADE, "dexId" TEXT NOT NULL REFERENCES game_dexes(id) ON DELETE CASCADE, PRIMARY KEY ("pokedexId", "dexId") ); CREATE INDEX idx_pokedex_dex_scopes_pokedex_id ON pokedex_dex_scopes("pokedexId"); CREATE INDEX idx_pokedex_dex_scopes_dex_id ON pokedex_dex_scopes("dexId"); -- 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_game_dexes_updated_at BEFORE UPDATE ON game_dexes 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 game_dexes ENABLE ROW LEVEL SECURITY; CREATE POLICY "Game dexes are publicly readable" ON game_dexes 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_dex_scopes ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view own dex scopes" ON pokedex_dex_scopes FOR SELECT USING ( EXISTS ( SELECT 1 FROM pokedexes WHERE pokedexes.id = pokedex_dex_scopes."pokedexId" AND pokedexes."userId" = auth.uid() ) ); CREATE POLICY "Users can manage own dex scopes" ON pokedex_dex_scopes FOR ALL USING ( EXISTS ( SELECT 1 FROM pokedexes WHERE pokedexes.id = pokedex_dex_scopes."pokedexId" AND pokedexes."userId" = auth.uid() ) ); 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."spriteKey", p."canGigantamax", r.name AS "regionToCatchIn", r."releaseOrder" AS "regionReleaseOrder", 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", CASE WHEN p.form IS NULL OR lower(p.form) = 'male' THEN 0 WHEN lower(p.form) = 'female' THEN 1 WHEN lower(p.form) LIKE '%alolan%' OR lower(p.form) LIKE '%galarian%' OR lower(p.form) LIKE '%hisuian%' OR lower(p.form) LIKE '%paldean%' THEN 2 ELSE 3 END AS "formSortBucket", CASE WHEN lower(p.form) LIKE '%alolan%' OR lower(p.form) LIKE '%galarian%' OR lower(p.form) LIKE '%hisuian%' OR lower(p.form) LIKE '%paldean%' THEN r."releaseOrder" ELSE 0 END AS "formSortRegionOrder", CASE WHEN lower(p.form) LIKE 'female-%' THEN 1 ELSE 0 END AS "formSortRegionalSub", COALESCE(lower(p.form), '') AS "formSortLabel", CASE WHEN p.pokemon = 'Unown' THEN CASE WHEN p.form = '?' THEN 26 WHEN p.form = '!' THEN 27 WHEN length(p.form) = 1 AND ascii(upper(p.form)) BETWEEN 65 AND 90 THEN ascii(upper(p.form)) - 65 ELSE 28 END ELSE 0 END AS "unownSortOrder" 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, r."releaseOrder"; GRANT SELECT ON pokedex_entries TO anon, authenticated; -- Dex-scoped view for ordering and filtering by native dexes CREATE OR REPLACE VIEW game_pokedex_entry_details AS SELECT gpe."dexId", gd."displayName" AS "dexDisplayName", gd."sortOrder" AS "dexSortOrder", gpe."dexNumber", pe.* FROM game_pokedex_entries gpe JOIN game_dexes gd ON gd.id = gpe."dexId" JOIN pokedex_entries pe ON pe.id = gpe."pokemonId"; GRANT SELECT ON game_pokedex_entry_details 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 SET search_path = public, pg_temp 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; $$;