Files
xiaomai 22016365d8 feat: add pokemon trading preferences and item tag inference
Introduce trading preference (Likes/Neutral) for Pokemon with trading skills
Infer possible hidden tags for items based on trading observations
Update import/export, wipe, and admin config to support trading data
2026-05-05 22:54:32 +08:00

1279 lines
52 KiB
SQL

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',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
)
),
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', 'effect', 'mosslaxEffect')),
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);
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 user_follows (
follower_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
followed_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (follower_user_id, followed_user_id),
CHECK (follower_user_id <> followed_user_id)
);
CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
ON user_follows(followed_user_id, created_at DESC, follower_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)
);
INSERT INTO ai_moderation_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
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,
reason text,
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),
('dish.create', 'Create Dish records', 'Create Dish categories and dish records.', 'Dish', true),
('dish.update', 'Update Dish records', 'Edit Dish categories and dish records.', 'Dish', true),
('dish.delete', 'Delete Dish records', 'Delete Dish categories and dish records.', 'Dish', true),
('dish.order', 'Order Dish records', 'Reorder Dish categories and dish records.', 'Dish', 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.comments.like', 'Like Life comments', 'Like and unlike Life comments.', '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),
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', 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),
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', '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',
'dish.create',
'dish.update',
'dish.delete',
'dish.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.comments.like',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
'discussions.comments.delete-any',
'discussions.comments.like'
])
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',
'dish.create',
'dish.update',
'dish.order',
'life.posts.create',
'life.posts.update',
'life.posts.delete',
'life.comments.create',
'life.comments.delete',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
'discussions.comments.like'
])
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[
'dish.create',
'dish.update',
'dish.delete',
'dish.order'
])
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[
'dish.create',
'dish.update',
'dish.order'
])
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.comments.like',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
'discussions.comments.like'
])
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;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'life.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
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 = 'discussions.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
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 = 'users.follow'
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_reason text,
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_reason text,
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_comment_likes (
comment_id integer NOT NULL REFERENCES life_post_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_comment_likes_comment_idx
ON life_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS life_comment_likes_user_idx
ON life_comment_likes(user_id, created_at DESC, comment_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,
has_trading 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()
);
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 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,
name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '',
base_price integer,
ancient_artifact_category_key text,
category_key text NOT NULL DEFAULT 'other',
usage_key text,
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 (
ancient_artifact_category_key IS NULL
OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
),
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
);
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 pokemon_trading_items (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
preference text NOT NULL CHECK (preference IN ('like', 'neutral')),
PRIMARY KEY (pokemon_id, item_id)
);
CREATE INDEX IF NOT EXISTS pokemon_trading_items_item_idx
ON pokemon_trading_items(item_id, preference, pokemon_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 dish_categories (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
cookware_item_id integer NOT NULL REFERENCES items(id),
main_material_item_id integer NOT NULL REFERENCES items(id),
total_material_quantity integer NOT NULL DEFAULT 2 CHECK (total_material_quantity >= 2),
effect 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 dish_flavors (
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 dishes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
category_id integer NOT NULL REFERENCES dish_categories(id) ON DELETE CASCADE,
item_id integer NOT NULL UNIQUE REFERENCES items(id),
flavor_id integer NOT NULL REFERENCES dish_flavors(id),
secondary_material_1_item_id integer REFERENCES items(id),
secondary_material_2_item_id integer REFERENCES items(id),
pokemon_skill_id integer REFERENCES skills(id),
mosslax_effect 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 (
secondary_material_1_item_id IS NULL
OR secondary_material_2_item_id IS NULL
OR secondary_material_1_item_id <> secondary_material_2_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)
);
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 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 INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_category_sort_order_idx ON dishes(category_id, sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_sort_order_idx ON dishes(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 !~ '(^/|\\.\\.)')
);
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_reason text,
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);
CREATE TABLE IF NOT EXISTS entity_discussion_comment_likes (
comment_id integer NOT NULL REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
ON entity_discussion_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
CREATE TABLE IF NOT EXISTS notifications (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
recipient_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_user_id integer REFERENCES users(id) ON DELETE SET NULL,
type text NOT NULL CHECK (
type IN (
'life_post_comment',
'life_comment_reply',
'discussion_comment_reply',
'life_post_reaction',
'user_follow',
'moderation_result'
)
),
life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE,
profile_user_id integer REFERENCES users(id) ON DELETE CASCADE,
life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE,
parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
parent_discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE SET NULL,
entity_type text CHECK (
entity_type IS NULL OR entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
),
entity_id integer,
reaction_type text CHECK (reaction_type IS NULL OR reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
moderation_status text CHECK (moderation_status IS NULL OR moderation_status IN ('approved', 'rejected', 'failed')),
moderation_reason text,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS notifications_recipient_created_idx
ON notifications(recipient_user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS notifications_recipient_unread_idx
ON notifications(recipient_user_id, created_at DESC, id DESC)
WHERE read_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_comment_unique_idx
ON notifications(recipient_user_id, life_comment_id)
WHERE type = 'life_post_comment' AND life_comment_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_comment_reply_unique_idx
ON notifications(recipient_user_id, life_comment_id)
WHERE type = 'life_comment_reply' AND life_comment_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_discussion_comment_reply_unique_idx
ON notifications(recipient_user_id, discussion_comment_id)
WHERE type = 'discussion_comment_reply' AND discussion_comment_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
ON notifications(recipient_user_id, actor_user_id, life_post_id)
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
ON notifications(recipient_user_id, actor_user_id, profile_user_id)
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS notification_ws_tickets (
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 notification_ws_tickets_user_idx
ON notification_ws_tickets(user_id, expires_at DESC);
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 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;
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;