Files
LivingDexTracker/supabase/migrations/20260118000000_init_schema.sql
T
2026-01-17 21:08:21 +00:00

460 lines
14 KiB
PL/PgSQL

-- 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: 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",
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;
-- 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;
$$;