CREATE TABLE IF NOT EXISTS languages ( code text PRIMARY KEY, name text NOT NULL, enabled boolean NOT NULL DEFAULT true, is_default boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), CHECK (code ~ '^[a-z]{2}(-[A-Z]{2})?$'), CHECK (length(name) BETWEEN 1 AND 80) ); CREATE UNIQUE INDEX IF NOT EXISTS languages_single_default_idx ON languages (is_default) WHERE is_default = true; INSERT INTO languages (code, name, enabled, is_default, sort_order) VALUES ('en', 'English', true, true, 10), ('zh-CN', '简体中文', true, false, 20) ON CONFLICT (code) DO NOTHING; CREATE TABLE IF NOT EXISTS entity_translations ( entity_type text NOT NULL CHECK ( entity_type IN ( 'pokemon', 'pokemon-types', 'skills', 'environments', 'favorite-things', 'item-categories', 'item-usages', 'acquisition-methods', 'items', 'ancient-artifacts', 'maps', 'habitats', 'daily-checklist-items', 'life-tags', 'game-versions' ) ), entity_id integer NOT NULL, locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')), value text NOT NULL, PRIMARY KEY (entity_type, entity_id, locale, field_name) ); CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx ON entity_translations (entity_type, entity_id, field_name, locale); ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check; ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_check CHECK ( entity_type IN ( 'pokemon', 'pokemon-types', 'skills', 'environments', 'favorite-things', 'item-categories', 'item-usages', 'acquisition-methods', 'items', 'ancient-artifacts', 'maps', 'habitats', 'daily-checklist-items', 'life-tags', 'game-versions' ) ); CREATE TABLE IF NOT EXISTS users ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, display_name text NOT NULL, password_hash text NOT NULL, referral_code text, referred_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, email_verified_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (email = lower(email)), CHECK (length(display_name) BETWEEN 1 AND 40), CHECK (referral_code IS NULL OR referral_code ~ '^[A-Z0-9]{8,16}$') ); CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx ON users(referral_code) WHERE referral_code IS NOT NULL; CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx ON users(referred_by_user_id); CREATE TABLE IF NOT EXISTS environments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS roles ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, key text NOT NULL UNIQUE, name text NOT NULL, description text NOT NULL DEFAULT '', level integer NOT NULL DEFAULT 0 CHECK (level >= 0), enabled boolean NOT NULL DEFAULT true, system_role boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (key ~ '^[a-z][a-z0-9-]{1,63}$'), CHECK (length(name) BETWEEN 1 AND 80) ); CREATE INDEX IF NOT EXISTS roles_level_idx ON roles(level DESC, id); CREATE TABLE IF NOT EXISTS permissions ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, key text NOT NULL UNIQUE, name text NOT NULL, description text NOT NULL DEFAULT '', category text NOT NULL DEFAULT 'General', enabled boolean NOT NULL DEFAULT true, system_permission boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (key ~ '^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$'), CHECK (length(name) BETWEEN 1 AND 120), CHECK (length(category) BETWEEN 1 AND 80) ); CREATE INDEX IF NOT EXISTS permissions_category_idx ON permissions(category, key); CREATE TABLE IF NOT EXISTS role_permissions ( role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id integer NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (role_id, permission_id) ); CREATE INDEX IF NOT EXISTS role_permissions_permission_id_idx ON role_permissions(permission_id, role_id); CREATE TABLE IF NOT EXISTS user_roles ( user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE, assigned_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, assigned_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, role_id) ); CREATE INDEX IF NOT EXISTS user_roles_role_id_idx ON user_roles(role_id, user_id); CREATE TABLE IF NOT EXISTS ai_moderation_settings ( id boolean PRIMARY KEY DEFAULT true CHECK (id = true), enabled boolean NOT NULL DEFAULT true, api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')), auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token')), endpoint text NOT NULL DEFAULT 'https://ai.example.com/v1beta', api_key text NOT NULL DEFAULT '', model text NOT NULL DEFAULT 'gemini-2.0-flash-lite', requests_per_minute integer NOT NULL DEFAULT 10 CHECK (requests_per_minute BETWEEN 1 AND 60), updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (length(endpoint) BETWEEN 1 AND 300), CHECK (length(model) BETWEEN 1 AND 120) ); ALTER TABLE ai_moderation_settings ADD COLUMN IF NOT EXISTS api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')), ADD COLUMN IF NOT EXISTS auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token')); INSERT INTO ai_moderation_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; UPDATE ai_moderation_settings SET api_format = 'gemini-generate-content', auth_mode = 'bearer-token', updated_at = now() WHERE api_format = 'openai-chat-completions' AND auth_mode = 'query-key' AND endpoint ~* '/v1beta/?$'; CREATE TABLE IF NOT EXISTS ai_moderation_cache ( content_hash text NOT NULL, model text NOT NULL, status text NOT NULL CHECK (status IN ('approved', 'rejected')), language_code text REFERENCES languages(code) ON DELETE SET NULL, checked_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (content_hash, model), CHECK (length(content_hash) BETWEEN 32 AND 128), CHECK (length(model) BETWEEN 1 AND 120) ); CREATE TABLE IF NOT EXISTS rate_limit_settings ( id boolean PRIMARY KEY DEFAULT true CHECK (id = true), settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'), updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO rate_limit_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; INSERT INTO permissions (key, name, description, category, system_permission) VALUES ('admin.access', 'Access admin', 'Open the management area.', 'Admin', true), ('admin.users.read', 'View users', 'View user role assignments.', 'Users', true), ('admin.users.update', 'Manage user roles', 'Assign and remove roles from users.', 'Users', true), ('admin.users.assign-owner', 'Assign Owner role', 'Assign and remove the Owner role from users.', 'Users', true), ('admin.roles.read', 'View roles', 'View role configuration.', 'Roles', true), ('admin.roles.create', 'Create roles', 'Create configurable roles.', 'Roles', true), ('admin.roles.update', 'Update roles', 'Edit roles and role permission assignments.', 'Roles', true), ('admin.roles.delete', 'Delete roles', 'Delete configurable roles.', 'Roles', true), ('admin.permissions.read', 'View permissions', 'View permission configuration.', 'Permissions', true), ('admin.permissions.create', 'Create permissions', 'Create configurable permissions.', 'Permissions', true), ('admin.permissions.update', 'Update permissions', 'Edit permission metadata and enabled state.', 'Permissions', true), ('admin.permissions.delete', 'Delete permissions', 'Delete configurable permissions.', 'Permissions', true), ('admin.languages.read', 'View languages', 'View language settings.', 'Languages', true), ('admin.languages.create', 'Create languages', 'Create languages.', 'Languages', true), ('admin.languages.update', 'Update languages', 'Edit language settings.', 'Languages', true), ('admin.languages.delete', 'Delete languages', 'Delete languages.', 'Languages', true), ('admin.languages.order', 'Order languages', 'Reorder languages.', 'Languages', true), ('admin.wordings.read', 'View system wordings', 'View system wording values.', 'System wordings', true), ('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true), ('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true), ('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true), ('admin.rate-limits.read', 'View rate limits', 'View user rate limit settings.', 'Rate limits', true), ('admin.rate-limits.update', 'Update rate limits', 'Edit user rate limit settings.', 'Rate limits', true), ('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true), ('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true), ('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true), ('admin.config.delete', 'Delete system config', 'Delete management configuration records.', 'System config', true), ('admin.config.order', 'Order system config', 'Reorder management configuration records.', 'System config', true), ('admin.data.export', 'Export data', 'Export content data bundles.', 'Data tools', true), ('admin.data.import', 'Import and wipe data', 'Import content data bundles and wipe content data.', 'Data tools', true), ('checklist.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true), ('checklist.update', 'Update checklist tasks', 'Edit Daily CheckList tasks.', 'CheckList', true), ('checklist.delete', 'Delete checklist tasks', 'Delete Daily CheckList tasks.', 'CheckList', true), ('checklist.order', 'Order checklist tasks', 'Reorder Daily CheckList tasks.', 'CheckList', true), ('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true), ('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true), ('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true), ('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true), ('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true), ('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true), ('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true), ('habitats.update', 'Update habitats', 'Edit habitat records.', 'Habitats', true), ('habitats.delete', 'Delete habitats', 'Delete habitat records.', 'Habitats', true), ('habitats.order', 'Order habitats', 'Reorder habitat records.', 'Habitats', true), ('habitats.upload', 'Upload habitat images', 'Upload habitat images.', 'Habitats', true), ('items.create', 'Create items', 'Create item records.', 'Items', true), ('items.update', 'Update items', 'Edit item records.', 'Items', true), ('items.delete', 'Delete items', 'Delete item records.', 'Items', true), ('items.order', 'Order items', 'Reorder item records.', 'Items', true), ('items.upload', 'Upload item images', 'Upload item images.', 'Items', true), ('ancient-artifacts.create', 'Create Ancient Artifacts', 'Create Ancient Artifact records.', 'Ancient Artifacts', true), ('ancient-artifacts.update', 'Update Ancient Artifacts', 'Edit Ancient Artifact records.', 'Ancient Artifacts', true), ('ancient-artifacts.delete', 'Delete Ancient Artifacts', 'Delete Ancient Artifact records.', 'Ancient Artifacts', true), ('ancient-artifacts.order', 'Order Ancient Artifacts', 'Reorder Ancient Artifact records.', 'Ancient Artifacts', true), ('ancient-artifacts.upload', 'Upload Ancient Artifact images', 'Upload Ancient Artifact images.', 'Ancient Artifacts', true), ('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true), ('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true), ('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true), ('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true), ('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true), ('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true), ('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true), ('life.posts.update-any', 'Update any Life post', 'Edit any Life post.', 'Life', true), ('life.posts.delete-any', 'Delete any Life post', 'Delete any Life post.', 'Life', true), ('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true), ('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true), ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true), ('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true), ('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true), ('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true), ('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true), ('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true) ON CONFLICT (key) DO NOTHING; INSERT INTO roles (key, name, description, level, enabled, system_role) VALUES ('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true), ('admin', 'Admin', 'System manager with content, configuration and user administration permissions.', 800, true, true), ('editor', 'Editor', 'Wiki editor with content creation, update, sorting and community permissions.', 500, true, true), ('member', 'Member', 'Community member with Life and discussion permissions.', 100, true, true), ('viewer', 'Viewer', 'Read-only role for explicit access grouping.', 0, true, true) ON CONFLICT (key) DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.key = 'owner' ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'admin.access', 'admin.users.read', 'admin.users.update', 'admin.roles.read', 'admin.roles.create', 'admin.roles.update', 'admin.roles.delete', 'admin.permissions.read', 'admin.permissions.create', 'admin.permissions.update', 'admin.permissions.delete', 'admin.languages.read', 'admin.languages.create', 'admin.languages.update', 'admin.languages.delete', 'admin.languages.order', 'admin.wordings.read', 'admin.wordings.update', 'admin.ai-moderation.read', 'admin.ai-moderation.update', 'admin.rate-limits.read', 'admin.rate-limits.update', 'admin.config.read', 'admin.config.create', 'admin.config.update', 'admin.config.delete', 'admin.config.order', 'checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order', 'pokemon.create', 'pokemon.update', 'pokemon.delete', 'pokemon.order', 'pokemon.fetch', 'pokemon.upload', 'habitats.create', 'habitats.update', 'habitats.delete', 'habitats.order', 'habitats.upload', 'items.create', 'items.update', 'items.delete', 'items.order', 'items.upload', 'ancient-artifacts.create', 'ancient-artifacts.update', 'ancient-artifacts.delete', 'ancient-artifacts.order', 'ancient-artifacts.upload', 'recipes.create', 'recipes.update', 'recipes.delete', 'recipes.order', 'life.posts.create', 'life.posts.update', 'life.posts.delete', 'life.posts.update-any', 'life.posts.delete-any', 'life.comments.create', 'life.comments.delete', 'life.comments.delete-any', 'life.reactions.set', 'life.ratings.set', 'discussions.comments.create', 'discussions.comments.delete', 'discussions.comments.delete-any' ]) WHERE r.key = 'admin' AND NOT EXISTS ( SELECT 1 FROM role_permissions existing_role_permission WHERE existing_role_permission.role_id = r.id ) ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'admin.ai-moderation.read', 'admin.ai-moderation.update' ]) WHERE r.key = 'admin' ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'admin.rate-limits.read', 'admin.rate-limits.update' ]) WHERE r.key = 'admin' ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'admin.access', 'admin.config.read', 'checklist.create', 'checklist.update', 'checklist.order', 'pokemon.create', 'pokemon.update', 'pokemon.order', 'pokemon.fetch', 'pokemon.upload', 'habitats.create', 'habitats.update', 'habitats.order', 'habitats.upload', 'items.create', 'items.update', 'items.order', 'items.upload', 'ancient-artifacts.create', 'ancient-artifacts.update', 'ancient-artifacts.order', 'ancient-artifacts.upload', 'recipes.create', 'recipes.update', 'recipes.order', 'life.posts.create', 'life.posts.update', 'life.posts.delete', 'life.comments.create', 'life.comments.delete', 'life.reactions.set', 'life.ratings.set', 'discussions.comments.create', 'discussions.comments.delete' ]) WHERE r.key = 'editor' AND NOT EXISTS ( SELECT 1 FROM role_permissions existing_role_permission WHERE existing_role_permission.role_id = r.id ) ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'ancient-artifacts.create', 'ancient-artifacts.update', 'ancient-artifacts.delete', 'ancient-artifacts.order', 'ancient-artifacts.upload' ]) WHERE r.key = 'admin' ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'ancient-artifacts.create', 'ancient-artifacts.update', 'ancient-artifacts.order', 'ancient-artifacts.upload' ]) WHERE r.key = 'editor' ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = ANY (ARRAY[ 'life.posts.create', 'life.posts.update', 'life.posts.delete', 'life.comments.create', 'life.comments.delete', 'life.reactions.set', 'life.ratings.set', 'discussions.comments.create', 'discussions.comments.delete' ]) WHERE r.key = 'member' AND NOT EXISTS ( SELECT 1 FROM role_permissions existing_role_permission WHERE existing_role_permission.role_id = r.id ) ON CONFLICT DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.key = 'life.ratings.set' WHERE r.key IN ('admin', 'editor', 'member') ON CONFLICT DO NOTHING; WITH first_owner_user AS ( SELECT u.id FROM users u WHERE u.email_verified_at IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM user_roles ur JOIN roles existing_role ON existing_role.id = ur.role_id WHERE existing_role.key = 'owner' ) ORDER BY u.email_verified_at ASC, u.id ASC LIMIT 1 ) INSERT INTO user_roles (user_id, role_id) SELECT first_owner_user.id, r.id FROM first_owner_user CROSS JOIN roles r WHERE r.key = 'owner' ON CONFLICT DO NOTHING; INSERT INTO user_roles (user_id, role_id) SELECT u.id, r.id FROM users u CROSS JOIN roles r WHERE u.email_verified_at IS NOT NULL AND r.key = 'editor' AND NOT EXISTS ( SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id ) ON CONFLICT DO NOTHING; CREATE TABLE IF NOT EXISTS system_wording_keys ( key text PRIMARY KEY, module text NOT NULL, surface text NOT NULL CHECK (surface IN ('frontend', 'backend', 'email')), description text NOT NULL DEFAULT '', placeholders jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(placeholders) = 'array'), enabled boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (key ~ '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$'), CHECK (length(module) BETWEEN 1 AND 80) ); CREATE INDEX IF NOT EXISTS system_wording_keys_module_idx ON system_wording_keys(module, key); CREATE INDEX IF NOT EXISTS system_wording_keys_surface_idx ON system_wording_keys(surface, key); CREATE TABLE IF NOT EXISTS system_wording_values ( key text NOT NULL REFERENCES system_wording_keys(key) ON DELETE CASCADE, locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, value text NOT NULL CHECK (length(value) > 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (key, locale) ); CREATE INDEX IF NOT EXISTS system_wording_values_locale_idx ON system_wording_values(locale, key); CREATE TABLE IF NOT EXISTS email_verification_tokens ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, expires_at timestamptz NOT NULL, used_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx ON email_verification_tokens(user_id); CREATE TABLE IF NOT EXISTS password_reset_tokens ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, expires_at timestamptz NOT NULL, used_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx ON password_reset_tokens(user_id); CREATE TABLE IF NOT EXISTS user_sessions ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx ON user_sessions(user_id); CREATE TABLE IF NOT EXISTS daily_checklist_items ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx ON daily_checklist_items(sort_order, id); CREATE TABLE IF NOT EXISTS life_tags ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, is_default boolean NOT NULL DEFAULT false, is_rateable boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS game_versions ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, change_log text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx ON game_versions(sort_order, id); CREATE TABLE IF NOT EXISTS life_posts ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000), category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT, game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL, ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ai_moderation_content_hash text, ai_moderation_checked_at timestamptz, ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, deleted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS life_posts_created_at_idx ON life_posts(created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx ON life_posts(created_at DESC, id DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS life_posts_user_created_at_idx ON life_posts(created_by_user_id, created_at DESC, id DESC) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS life_post_tags ( post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE, PRIMARY KEY (post_id, tag_id) ); CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx ON life_post_tags(tag_id, post_id); CREATE TABLE IF NOT EXISTS life_post_comments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, parent_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL, body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000), ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ai_moderation_content_hash text, ai_moderation_checked_at timestamptz, ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, deleted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS life_post_comments_post_idx ON life_post_comments(post_id, created_at, id); CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx ON life_post_comments(parent_comment_id, created_at, id); CREATE INDEX IF NOT EXISTS life_post_comments_user_idx ON life_post_comments(created_by_user_id, created_at DESC, id DESC); CREATE TABLE IF NOT EXISTS life_post_reactions ( post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (post_id, user_id) ); CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx ON life_post_reactions(post_id, reaction_type); CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx ON life_post_reactions(user_id, updated_at DESC, post_id DESC); CREATE TABLE IF NOT EXISTS life_post_ratings ( post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (post_id, user_id) ); CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx ON life_post_ratings(post_id, rating); CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx ON life_post_ratings(user_id, updated_at DESC, post_id DESC); CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, has_item_drop boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS favorite_things ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS pokemon_types ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS pokemon ( id integer PRIMARY KEY, data_id integer CHECK (data_id > 0), data_identifier text NOT NULL DEFAULT '', display_id integer NOT NULL CHECK (display_id > 0), name text NOT NULL UNIQUE, is_event_item boolean NOT NULL DEFAULT false, genus text NOT NULL DEFAULT '', details text NOT NULL DEFAULT '', height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0), weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0), environment_id integer NOT NULL REFERENCES environments(id), hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0), attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0), defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0), special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0), special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0), speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0), image_path text NOT NULL DEFAULT '', image_style text NOT NULL DEFAULT '', image_version text NOT NULL DEFAULT '', image_variant text NOT NULL DEFAULT '', image_description text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0), ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS pokemon_pokemon_types ( pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE, slot_order integer NOT NULL CHECK (slot_order BETWEEN 1 AND 2), PRIMARY KEY (pokemon_id, type_id), UNIQUE (pokemon_id, slot_order) ); CREATE TABLE IF NOT EXISTS pokemon_skills ( pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, skill_id integer NOT NULL REFERENCES skills(id), PRIMARY KEY (pokemon_id, skill_id) ); CREATE TABLE IF NOT EXISTS pokemon_favorite_things ( pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, favorite_thing_id integer NOT NULL REFERENCES favorite_things(id), PRIMARY KEY (pokemon_id, favorite_thing_id) ); CREATE TABLE IF NOT EXISTS item_categories ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS item_usages ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS acquisition_methods ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS items ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, display_id integer NOT NULL CHECK (display_id > 0), name text NOT NULL UNIQUE, details text NOT NULL DEFAULT '', category_key text NOT NULL DEFAULT 'other', usage_key text, category_id integer REFERENCES item_categories(id), usage_id integer REFERENCES item_usages(id), dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false, pattern_editable boolean NOT NULL DEFAULT false, no_recipe boolean NOT NULL DEFAULT false, is_event_item boolean NOT NULL DEFAULT false, image_path text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (category_key IN ( 'furniture', 'misc', 'outdoor', 'utilities', 'buildings', 'blocks', 'kits', 'nature', 'food', 'materials', 'key-items', 'other' )), CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road')) ); CREATE TABLE IF NOT EXISTS ancient_artifacts ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, display_id integer NOT NULL UNIQUE CHECK (display_id > 0), name text NOT NULL UNIQUE, details text NOT NULL DEFAULT '', category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')), image_path text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS recipes ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, item_id integer NOT NULL UNIQUE REFERENCES items(id), sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS recipe_acquisition_methods ( recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), PRIMARY KEY (recipe_id, acquisition_method_id) ); CREATE TABLE IF NOT EXISTS item_acquisition_methods ( item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), PRIMARY KEY (item_id, acquisition_method_id) ); CREATE TABLE IF NOT EXISTS item_favorite_things ( item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, favorite_thing_id integer NOT NULL REFERENCES favorite_things(id), PRIMARY KEY (item_id, favorite_thing_id) ); CREATE TABLE IF NOT EXISTS ancient_artifact_favorite_things ( ancient_artifact_id integer NOT NULL REFERENCES ancient_artifacts(id) ON DELETE CASCADE, favorite_thing_id integer NOT NULL REFERENCES favorite_things(id), PRIMARY KEY (ancient_artifact_id, favorite_thing_id) ); CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops ( pokemon_id integer NOT NULL, skill_id integer NOT NULL, item_id integer NOT NULL REFERENCES items(id), PRIMARY KEY (pokemon_id, skill_id), FOREIGN KEY (pokemon_id, skill_id) REFERENCES pokemon_skills(pokemon_id, skill_id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS recipe_materials ( recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, item_id integer NOT NULL REFERENCES items(id), quantity integer NOT NULL CHECK (quantity > 0), PRIMARY KEY (recipe_id, item_id) ); CREATE TABLE IF NOT EXISTS maps ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS habitats ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, is_event_item boolean NOT NULL DEFAULT false, image_path text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS habitat_recipe_items ( habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE, item_id integer NOT NULL REFERENCES items(id), quantity integer NOT NULL CHECK (quantity > 0), PRIMARY KEY (habitat_id, item_id) ); CREATE TABLE IF NOT EXISTS habitat_pokemon ( habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, map_id integer NOT NULL REFERENCES maps(id), time_of_day text NOT NULL CHECK (time_of_day IN ('早晨', '中午', '傍晚', '晚上')), weather text NOT NULL CHECK (weather IN ('晴天', '阴天', '雨天')), rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3), PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather) ); ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false; ALTER TABLE items ADD COLUMN IF NOT EXISTS display_id integer, ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS category_key text, ADD COLUMN IF NOT EXISTS usage_key text; ALTER TABLE ancient_artifacts ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'items' AND column_name = 'category_id' AND table_schema = current_schema() ) THEN ALTER TABLE items ALTER COLUMN category_id DROP NOT NULL; END IF; END $$; UPDATE items SET display_id = id WHERE display_id IS NULL; UPDATE items i SET category_key = CASE lower(trim(c.name)) WHEN 'furniture' THEN 'furniture' WHEN 'misc' THEN 'misc' WHEN 'outdoor' THEN 'outdoor' WHEN 'utilities' THEN 'utilities' WHEN 'buildings' THEN 'buildings' WHEN 'blocks' THEN 'blocks' WHEN 'kits' THEN 'kits' WHEN 'nature' THEN 'nature' WHEN 'food' THEN 'food' WHEN 'materials' THEN 'materials' WHEN 'key items' THEN 'key-items' WHEN 'key-items' THEN 'key-items' WHEN 'other' THEN 'other' ELSE 'other' END FROM item_categories c WHERE i.category_id = c.id AND (i.category_key IS NULL OR i.category_key = ''); UPDATE items i SET usage_key = CASE lower(trim(u.name)) WHEN 'decoration' THEN 'decoration' WHEN 'relaxation' THEN 'relaxation' WHEN 'toy' THEN 'toy' WHEN 'road' THEN 'road' ELSE NULL END FROM item_usages u WHERE i.usage_id = u.id AND i.usage_key IS NULL; UPDATE items SET category_key = 'other' WHERE category_key IS NULL OR category_key NOT IN ( 'furniture', 'misc', 'outdoor', 'utilities', 'buildings', 'blocks', 'kits', 'nature', 'food', 'materials', 'key-items', 'other' ); UPDATE items SET usage_key = NULL WHERE usage_key IS NOT NULL AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road'); ALTER TABLE items ALTER COLUMN display_id SET NOT NULL, ALTER COLUMN category_key SET NOT NULL, ALTER COLUMN category_key SET DEFAULT 'other'; ALTER TABLE items DROP CONSTRAINT IF EXISTS items_display_id_positive, DROP CONSTRAINT IF EXISTS items_category_key_check, DROP CONSTRAINT IF EXISTS items_usage_key_check; ALTER TABLE items ADD CONSTRAINT items_display_id_positive CHECK (display_id > 0), ADD CONSTRAINT items_category_key_check CHECK (category_key IN ( 'furniture', 'misc', 'outdoor', 'utilities', 'buildings', 'blocks', 'kits', 'nature', 'food', 'materials', 'key-items', 'other' )), ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road')); CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id); CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id); CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id); CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item); CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id); CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true; CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id); CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id); CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id); CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id); CREATE UNIQUE INDEX IF NOT EXISTS items_display_event_item_key ON items(display_id, is_event_item); CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, display_id, sort_order, id); CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id); CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id); CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id); CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id); CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id); CREATE TABLE IF NOT EXISTS wiki_edit_logs ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, entity_type text NOT NULL, entity_id integer NOT NULL, action text NOT NULL CHECK (action IN ('create', 'update', 'delete')), user_id integer REFERENCES users(id) ON DELETE SET NULL, changes jsonb NOT NULL DEFAULT '[]'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx ON wiki_edit_logs(entity_type, entity_id, created_at DESC); CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx ON wiki_edit_logs(user_id); CREATE TABLE IF NOT EXISTS entity_image_uploads ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')), entity_id integer, entity_name text NOT NULL, path text NOT NULL UNIQUE, original_filename text NOT NULL DEFAULT '', mime_type text NOT NULL CHECK (mime_type IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')), byte_size integer NOT NULL CHECK (byte_size > 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), CHECK (length(entity_name) BETWEEN 1 AND 120), CHECK (path !~ '(^/|\\.\\.)') ); ALTER TABLE entity_image_uploads DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check; ALTER TABLE entity_image_uploads ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK ( entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts') ); CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS entity_image_uploads_user_idx ON entity_image_uploads(created_by_user_id); CREATE TABLE IF NOT EXISTS entity_discussion_comments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')), entity_id integer NOT NULL, parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE, body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000), ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ai_moderation_content_hash text, ai_moderation_checked_at timestamptz, ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, deleted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS entity_discussion_comments_entity_idx ON entity_discussion_comments(entity_type, entity_id, created_at, id); CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx ON entity_discussion_comments(parent_comment_id, created_at, id); CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx ON entity_discussion_comments(created_by_user_id); ALTER TABLE entity_discussion_comments DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check; ALTER TABLE entity_discussion_comments ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK ( entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts') ); ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false; CREATE TABLE IF NOT EXISTS game_versions ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, change_log text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx ON game_versions(sort_order, id); ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT, ADD COLUMN IF NOT EXISTS game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text, ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz, ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(); UPDATE life_posts lp SET category_id = selected.tag_id FROM ( SELECT DISTINCT ON (lpt.post_id) lpt.post_id, lpt.tag_id FROM life_post_tags lpt JOIN life_tags lt ON lt.id = lpt.tag_id ORDER BY lpt.post_id, lt.sort_order, lt.id ) selected WHERE lp.id = selected.post_id AND lp.category_id IS NULL; CREATE INDEX IF NOT EXISTS life_posts_category_idx ON life_posts(category_id, created_at DESC, id DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS life_posts_game_version_idx ON life_posts(game_version_id, created_at DESC, id DESC) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS life_post_ratings ( post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (post_id, user_id) ); CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx ON life_post_ratings(post_id, rating); CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx ON life_post_ratings(user_id, updated_at DESC, post_id DESC); ALTER TABLE life_post_comments ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text, ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz, ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text, ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz, ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(); CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_status_idx ON life_posts(ai_moderation_status, ai_moderation_updated_at, id) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_language_idx ON life_posts(ai_moderation_language_code, created_at DESC, id DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS life_post_comments_ai_moderation_status_idx ON life_post_comments(ai_moderation_status, ai_moderation_updated_at, id) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS life_post_comments_ai_moderation_language_idx ON life_post_comments(ai_moderation_language_code, created_at, id) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx ON entity_discussion_comments(ai_moderation_status, ai_moderation_updated_at, id) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id) WHERE deleted_at IS NULL;