Add database schema, permissions, and API endpoints for dishes Implement frontend views and admin management for dish data
1650 lines
65 KiB
SQL
1650 lines
65 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',
|
|
'item-categories',
|
|
'item-usages',
|
|
'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);
|
|
|
|
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',
|
|
'dish-categories',
|
|
'dish-flavors',
|
|
'dishes'
|
|
)
|
|
);
|
|
|
|
ALTER TABLE entity_translations
|
|
DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
|
|
|
|
ALTER TABLE entity_translations
|
|
ADD CONSTRAINT entity_translations_field_name_check CHECK (
|
|
field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')
|
|
);
|
|
|
|
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)
|
|
);
|
|
|
|
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,
|
|
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)
|
|
);
|
|
|
|
ALTER TABLE ai_moderation_cache
|
|
ADD COLUMN IF NOT EXISTS reason text;
|
|
|
|
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,
|
|
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 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()
|
|
);
|
|
|
|
ALTER TABLE dish_categories
|
|
ADD COLUMN IF NOT EXISTS main_material_item_id integer REFERENCES items(id);
|
|
|
|
ALTER TABLE dish_categories
|
|
ADD COLUMN IF NOT EXISTS total_material_quantity integer NOT NULL DEFAULT 2;
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF to_regclass('public.dishes') IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'public'
|
|
AND table_name = 'dishes'
|
|
AND column_name = 'main_material_item_id'
|
|
)
|
|
THEN
|
|
EXECUTE '
|
|
UPDATE dish_categories dc
|
|
SET main_material_item_id = source.main_material_item_id
|
|
FROM (
|
|
SELECT DISTINCT ON (category_id) category_id, main_material_item_id
|
|
FROM dishes
|
|
WHERE main_material_item_id IS NOT NULL
|
|
ORDER BY category_id, sort_order, id
|
|
) AS source
|
|
WHERE dc.id = source.category_id
|
|
AND dc.main_material_item_id IS NULL
|
|
';
|
|
END IF;
|
|
END $$;
|
|
|
|
UPDATE dish_categories
|
|
SET main_material_item_id = cookware_item_id
|
|
WHERE main_material_item_id IS NULL;
|
|
|
|
ALTER TABLE dish_categories
|
|
ALTER COLUMN main_material_item_id SET NOT NULL;
|
|
|
|
ALTER TABLE dish_categories
|
|
ALTER COLUMN total_material_quantity SET DEFAULT 2;
|
|
|
|
UPDATE dish_categories
|
|
SET total_material_quantity = 2
|
|
WHERE total_material_quantity < 2;
|
|
|
|
ALTER TABLE dish_categories
|
|
DROP CONSTRAINT IF EXISTS dish_categories_total_material_quantity_check;
|
|
|
|
ALTER TABLE dish_categories
|
|
ADD CONSTRAINT dish_categories_total_material_quantity_check CHECK (total_material_quantity >= 2);
|
|
|
|
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
|
|
)
|
|
);
|
|
|
|
ALTER TABLE dishes
|
|
ADD COLUMN IF NOT EXISTS flavor_id integer REFERENCES dish_flavors(id);
|
|
|
|
ALTER TABLE dishes
|
|
ALTER COLUMN secondary_material_1_item_id DROP NOT NULL;
|
|
|
|
ALTER TABLE dishes
|
|
DROP COLUMN IF EXISTS main_material_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 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 !~ '(^/|\\.\\.)')
|
|
);
|
|
|
|
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_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);
|
|
|
|
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')
|
|
);
|
|
|
|
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;
|
|
|
|
ALTER TABLE notifications
|
|
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
|
|
|
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);
|
|
|
|
ALTER TABLE notifications
|
|
ADD COLUMN IF NOT EXISTS moderation_reason text;
|
|
|
|
ALTER TABLE notifications
|
|
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
|
|
|
ALTER TABLE notifications
|
|
DROP CONSTRAINT IF EXISTS notifications_type_check;
|
|
|
|
ALTER TABLE notifications
|
|
ADD CONSTRAINT notifications_type_check CHECK (
|
|
type IN (
|
|
'life_post_comment',
|
|
'life_comment_reply',
|
|
'discussion_comment_reply',
|
|
'life_post_reaction',
|
|
'user_follow',
|
|
'moderation_result'
|
|
)
|
|
);
|
|
|
|
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_reason text,
|
|
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_reason text,
|
|
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_reason text,
|
|
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;
|