Update bootstrap rules to grant 'editor' role to verified users Backfill existing verified users without roles in schema.sql Apply default role automatically during email verification
814 lines
33 KiB
SQL
814 lines
33 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',
|
|
'maps',
|
|
'habitats',
|
|
'daily-checklist-items',
|
|
'life-tags'
|
|
)
|
|
),
|
|
entity_id integer NOT NULL,
|
|
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
|
field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')),
|
|
value text NOT NULL,
|
|
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
|
ON entity_translations (entity_type, entity_id, field_name, locale);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
email text NOT NULL UNIQUE,
|
|
display_name text NOT NULL,
|
|
password_hash text NOT NULL,
|
|
referral_code text,
|
|
referred_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
email_verified_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CHECK (email = lower(email)),
|
|
CHECK (length(display_name) BETWEEN 1 AND 40),
|
|
CHECK (referral_code IS NULL OR referral_code ~ '^[A-Z0-9]{8,16}$')
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx
|
|
ON users(referral_code)
|
|
WHERE referral_code IS NOT NULL;
|
|
|
|
CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx
|
|
ON users(referred_by_user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS environments (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
name text NOT NULL UNIQUE,
|
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS roles (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
key text NOT NULL UNIQUE,
|
|
name text NOT NULL,
|
|
description text NOT NULL DEFAULT '',
|
|
level integer NOT NULL DEFAULT 0 CHECK (level >= 0),
|
|
enabled boolean NOT NULL DEFAULT true,
|
|
system_role boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CHECK (key ~ '^[a-z][a-z0-9-]{1,63}$'),
|
|
CHECK (length(name) BETWEEN 1 AND 80)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS roles_level_idx
|
|
ON roles(level DESC, id);
|
|
|
|
CREATE TABLE IF NOT EXISTS permissions (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
key text NOT NULL UNIQUE,
|
|
name text NOT NULL,
|
|
description text NOT NULL DEFAULT '',
|
|
category text NOT NULL DEFAULT 'General',
|
|
enabled boolean NOT NULL DEFAULT true,
|
|
system_permission boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CHECK (key ~ '^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$'),
|
|
CHECK (length(name) BETWEEN 1 AND 120),
|
|
CHECK (length(category) BETWEEN 1 AND 80)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS permissions_category_idx
|
|
ON permissions(category, key);
|
|
|
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
|
role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
|
permission_id integer NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (role_id, permission_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS role_permissions_permission_id_idx
|
|
ON role_permissions(permission_id, role_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_roles (
|
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
|
assigned_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (user_id, role_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS user_roles_role_id_idx
|
|
ON user_roles(role_id, user_id);
|
|
|
|
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.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.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),
|
|
('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),
|
|
('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true),
|
|
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
|
|
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
|
|
('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true),
|
|
('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true),
|
|
('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true),
|
|
('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true),
|
|
('life.posts.update-any', 'Update any Life post', 'Edit any Life post.', 'Life', true),
|
|
('life.posts.delete-any', 'Delete any Life post', 'Delete any Life post.', 'Life', true),
|
|
('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true),
|
|
('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true),
|
|
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
|
|
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
|
|
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
|
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
|
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
|
|
ON CONFLICT (key) DO NOTHING;
|
|
|
|
INSERT INTO roles (key, name, description, level, enabled, system_role)
|
|
VALUES
|
|
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
|
|
('admin', 'Admin', 'System manager with content, configuration and user administration permissions.', 800, true, true),
|
|
('editor', 'Editor', 'Wiki editor with content creation, update, sorting and community permissions.', 500, true, true),
|
|
('member', 'Member', 'Community member with Life and discussion permissions.', 100, true, true),
|
|
('viewer', 'Viewer', 'Read-only role for explicit access grouping.', 0, true, true)
|
|
ON CONFLICT (key) DO NOTHING;
|
|
|
|
INSERT INTO role_permissions (role_id, permission_id)
|
|
SELECT r.id, p.id
|
|
FROM roles r
|
|
CROSS JOIN permissions p
|
|
WHERE r.key = 'owner'
|
|
ON CONFLICT DO NOTHING;
|
|
|
|
INSERT INTO role_permissions (role_id, permission_id)
|
|
SELECT r.id, p.id
|
|
FROM roles r
|
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
|
'admin.access',
|
|
'admin.users.read',
|
|
'admin.users.update',
|
|
'admin.roles.read',
|
|
'admin.roles.create',
|
|
'admin.roles.update',
|
|
'admin.roles.delete',
|
|
'admin.permissions.read',
|
|
'admin.permissions.create',
|
|
'admin.permissions.update',
|
|
'admin.permissions.delete',
|
|
'admin.languages.read',
|
|
'admin.languages.create',
|
|
'admin.languages.update',
|
|
'admin.languages.delete',
|
|
'admin.languages.order',
|
|
'admin.wordings.read',
|
|
'admin.wordings.update',
|
|
'admin.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',
|
|
'recipes.create',
|
|
'recipes.update',
|
|
'recipes.delete',
|
|
'recipes.order',
|
|
'life.posts.create',
|
|
'life.posts.update',
|
|
'life.posts.delete',
|
|
'life.posts.update-any',
|
|
'life.posts.delete-any',
|
|
'life.comments.create',
|
|
'life.comments.delete',
|
|
'life.comments.delete-any',
|
|
'life.reactions.set',
|
|
'discussions.comments.create',
|
|
'discussions.comments.delete',
|
|
'discussions.comments.delete-any'
|
|
])
|
|
WHERE r.key = 'admin'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM role_permissions existing_role_permission
|
|
WHERE existing_role_permission.role_id = r.id
|
|
)
|
|
ON CONFLICT DO NOTHING;
|
|
|
|
INSERT INTO role_permissions (role_id, permission_id)
|
|
SELECT r.id, p.id
|
|
FROM roles r
|
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
|
'admin.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',
|
|
'recipes.create',
|
|
'recipes.update',
|
|
'recipes.order',
|
|
'life.posts.create',
|
|
'life.posts.update',
|
|
'life.posts.delete',
|
|
'life.comments.create',
|
|
'life.comments.delete',
|
|
'life.reactions.set',
|
|
'discussions.comments.create',
|
|
'discussions.comments.delete'
|
|
])
|
|
WHERE r.key = 'editor'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM role_permissions existing_role_permission
|
|
WHERE existing_role_permission.role_id = r.id
|
|
)
|
|
ON CONFLICT DO NOTHING;
|
|
|
|
INSERT INTO role_permissions (role_id, permission_id)
|
|
SELECT r.id, p.id
|
|
FROM roles r
|
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
|
'life.posts.create',
|
|
'life.posts.update',
|
|
'life.posts.delete',
|
|
'life.comments.create',
|
|
'life.comments.delete',
|
|
'life.reactions.set',
|
|
'discussions.comments.create',
|
|
'discussions.comments.delete'
|
|
])
|
|
WHERE r.key = 'member'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM role_permissions existing_role_permission
|
|
WHERE existing_role_permission.role_id = r.id
|
|
)
|
|
ON CONFLICT DO NOTHING;
|
|
|
|
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,
|
|
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 life_posts (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
|
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 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),
|
|
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 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 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,
|
|
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 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,
|
|
name text NOT NULL UNIQUE,
|
|
category_id integer NOT NULL 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()
|
|
);
|
|
|
|
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_skill_item_drops (
|
|
pokemon_id integer NOT NULL,
|
|
skill_id integer NOT NULL,
|
|
item_id integer NOT NULL REFERENCES items(id),
|
|
PRIMARY KEY (pokemon_id, skill_id),
|
|
FOREIGN KEY (pokemon_id, skill_id) REFERENCES pokemon_skills(pokemon_id, skill_id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS recipe_materials (
|
|
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
item_id integer NOT NULL REFERENCES items(id),
|
|
quantity integer NOT NULL CHECK (quantity > 0),
|
|
PRIMARY KEY (recipe_id, item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS maps (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
name text NOT NULL UNIQUE,
|
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS habitats (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
name text NOT NULL UNIQUE,
|
|
is_event_item boolean NOT NULL DEFAULT false,
|
|
image_path text NOT NULL DEFAULT '',
|
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS habitat_recipe_items (
|
|
habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
|
|
item_id integer NOT NULL REFERENCES items(id),
|
|
quantity integer NOT NULL CHECK (quantity > 0),
|
|
PRIMARY KEY (habitat_id, item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
|
habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
|
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
|
map_id integer NOT NULL REFERENCES maps(id),
|
|
time_of_day text NOT NULL CHECK (time_of_day IN ('早晨', '中午', '傍晚', '晚上')),
|
|
weather text NOT NULL CHECK (weather IN ('晴天', '阴天', '雨天')),
|
|
rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3),
|
|
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
|
);
|
|
|
|
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 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 INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
|
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
|
|
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
|
|
|
|
CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
entity_type text NOT NULL,
|
|
entity_id integer NOT NULL,
|
|
action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
|
user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
changes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
|
ON wiki_edit_logs(entity_type, entity_id, created_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
|
ON wiki_edit_logs(user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS entity_image_uploads (
|
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats')),
|
|
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')),
|
|
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),
|
|
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);
|