mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-12 18:43:45 +00:00
data(*): Add working regenerated data structure
This commit is contained in:
+1391
-2729
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user