-- Meshkee CMS — RBAC, content tables, media -- --------------------------------------------------------------------------- -- enums -- --------------------------------------------------------------------------- CREATE TYPE content_status AS ENUM ('draft', 'published', 'archived'); CREATE TYPE media_type AS ENUM ('image', 'video'); CREATE TYPE media_entity_type AS ENUM ('product', 'blog', 'portfolio'); -- --------------------------------------------------------------------------- -- permissions (RBAC) -- --------------------------------------------------------------------------- CREATE TABLE permissions ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(100) NOT NULL, slug VARCHAR(100) NOT NULL, group_name VARCHAR(50) NOT NULL, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT permissions_slug_unique UNIQUE (slug) ); CREATE TABLE roles ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(100) NOT NULL, slug VARCHAR(100) NOT NULL, description TEXT, is_system BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT roles_slug_unique UNIQUE (slug) ); CREATE TABLE role_permissions ( role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (role_id, permission_id), CONSTRAINT role_permissions_role_id_fkey FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, CONSTRAINT role_permissions_permission_id_fkey FOREIGN KEY (permission_id) REFERENCES permissions (id) ON DELETE CASCADE ); CREATE TABLE user_roles ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT user_roles_user_role_unique UNIQUE (user_id, role_id), CONSTRAINT user_roles_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, CONSTRAINT user_roles_role_id_fkey FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE ); CREATE INDEX idx_user_roles_user_id ON user_roles (user_id); CREATE INDEX idx_role_permissions_permission_id ON role_permissions (permission_id); CREATE TRIGGER roles_set_updated_at BEFORE UPDATE ON roles FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- --------------------------------------------------------------------------- -- media (images & videos, scoped per business) -- --------------------------------------------------------------------------- CREATE TABLE media ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, business_id BIGINT NOT NULL, uploaded_by BIGINT, media_type media_type NOT NULL, storage_disk VARCHAR(50) NOT NULL DEFAULT 'local', storage_path TEXT NOT NULL, public_url TEXT NOT NULL, file_name VARCHAR(255) NOT NULL, original_file_name VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL, file_size_bytes BIGINT NOT NULL, width INTEGER, height INTEGER, duration_seconds NUMERIC(10, 2), alt_text VARCHAR(255), caption TEXT, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT media_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT media_uploaded_by_fkey FOREIGN KEY (uploaded_by) REFERENCES users (id) ON DELETE SET NULL, CONSTRAINT media_file_size_positive CHECK (file_size_bytes > 0), CONSTRAINT media_image_dimensions CHECK ( media_type <> 'image' OR (width IS NOT NULL AND height IS NOT NULL) ), CONSTRAINT media_video_duration CHECK ( media_type <> 'video' OR duration_seconds IS NOT NULL ) ); CREATE INDEX idx_media_business_id ON media (business_id); CREATE INDEX idx_media_business_type ON media (business_id, media_type); CREATE INDEX idx_media_created_at ON media (business_id, created_at DESC); CREATE TRIGGER media_set_updated_at BEFORE UPDATE ON media FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- --------------------------------------------------------------------------- -- products -- --------------------------------------------------------------------------- CREATE TABLE products ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, business_id BIGINT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, description TEXT, content JSONB NOT NULL DEFAULT '{}', price NUMERIC(12, 2), compare_at_price NUMERIC(12, 2), sku VARCHAR(100), stock_quantity INTEGER, status content_status NOT NULL DEFAULT 'draft', featured_media_id BIGINT, sort_order INTEGER NOT NULL DEFAULT 0, published_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT products_business_slug_unique UNIQUE (business_id, slug), CONSTRAINT products_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT products_featured_media_id_fkey FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL, CONSTRAINT products_price_non_negative CHECK (price IS NULL OR price >= 0), CONSTRAINT products_compare_price_non_negative CHECK (compare_at_price IS NULL OR compare_at_price >= 0), CONSTRAINT products_stock_non_negative CHECK (stock_quantity IS NULL OR stock_quantity >= 0) ); CREATE INDEX idx_products_business_id ON products (business_id); CREATE INDEX idx_products_business_status ON products (business_id, status); CREATE INDEX idx_products_business_published ON products (business_id, published_at DESC); CREATE TRIGGER products_set_updated_at BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- --------------------------------------------------------------------------- -- blogs -- --------------------------------------------------------------------------- CREATE TABLE blogs ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, business_id BIGINT NOT NULL, author_id BIGINT, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, excerpt TEXT, content JSONB NOT NULL DEFAULT '{}', status content_status NOT NULL DEFAULT 'draft', featured_media_id BIGINT, published_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT blogs_business_slug_unique UNIQUE (business_id, slug), CONSTRAINT blogs_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT blogs_author_id_fkey FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL, CONSTRAINT blogs_featured_media_id_fkey FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL ); CREATE INDEX idx_blogs_business_id ON blogs (business_id); CREATE INDEX idx_blogs_business_status ON blogs (business_id, status); CREATE INDEX idx_blogs_business_published ON blogs (business_id, published_at DESC); CREATE TRIGGER blogs_set_updated_at BEFORE UPDATE ON blogs FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- --------------------------------------------------------------------------- -- portfolios -- --------------------------------------------------------------------------- CREATE TABLE portfolios ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, business_id BIGINT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, description TEXT, content JSONB NOT NULL DEFAULT '{}', client_name VARCHAR(255), project_url TEXT, status content_status NOT NULL DEFAULT 'draft', featured_media_id BIGINT, sort_order INTEGER NOT NULL DEFAULT 0, published_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT portfolios_business_slug_unique UNIQUE (business_id, slug), CONSTRAINT portfolios_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT portfolios_featured_media_id_fkey FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL ); CREATE INDEX idx_portfolios_business_id ON portfolios (business_id); CREATE INDEX idx_portfolios_business_status ON portfolios (business_id, status); CREATE INDEX idx_portfolios_business_published ON portfolios (business_id, published_at DESC); CREATE TRIGGER portfolios_set_updated_at BEFORE UPDATE ON portfolios FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- --------------------------------------------------------------------------- -- media attachments (galleries, inline images/videos on content) -- --------------------------------------------------------------------------- CREATE TABLE media_attachments ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, business_id BIGINT NOT NULL, media_id BIGINT NOT NULL, entity_type media_entity_type NOT NULL, entity_id BIGINT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, is_featured BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT media_attachments_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT media_attachments_media_id_fkey FOREIGN KEY (media_id) REFERENCES media (id) ON DELETE CASCADE, CONSTRAINT media_attachments_unique UNIQUE (media_id, entity_type, entity_id) ); CREATE INDEX idx_media_attachments_entity ON media_attachments (business_id, entity_type, entity_id, sort_order); CREATE INDEX idx_media_attachments_media_id ON media_attachments (media_id); -- --------------------------------------------------------------------------- -- seed: default permissions & roles -- --------------------------------------------------------------------------- INSERT INTO permissions (name, slug, group_name, description) VALUES ('View business', 'business.read', 'business', 'View business profile and settings'), ('Update business', 'business.update', 'business', 'Edit business profile and settings'), ('View domains', 'domains.read', 'domains', 'View connected domains'), ('Manage domains', 'domains.manage', 'domains', 'Add, edit, and remove domains'), ('View products', 'products.read', 'products', 'View products'), ('Create products', 'products.create', 'products', 'Create products'), ('Update products', 'products.update', 'products', 'Edit products'), ('Delete products', 'products.delete', 'products', 'Delete products'), ('Publish products', 'products.publish', 'products', 'Publish and unpublish products'), ('View blogs', 'blogs.read', 'blogs', 'View blog posts'), ('Create blogs', 'blogs.create', 'blogs', 'Create blog posts'), ('Update blogs', 'blogs.update', 'blogs', 'Edit blog posts'), ('Delete blogs', 'blogs.delete', 'blogs', 'Delete blog posts'), ('Publish blogs', 'blogs.publish', 'blogs', 'Publish and unpublish blog posts'), ('View portfolios', 'portfolios.read', 'portfolios', 'View portfolio items'), ('Create portfolios', 'portfolios.create', 'portfolios', 'Create portfolio items'), ('Update portfolios', 'portfolios.update', 'portfolios', 'Edit portfolio items'), ('Delete portfolios', 'portfolios.delete', 'portfolios', 'Delete portfolio items'), ('Publish portfolios', 'portfolios.publish', 'portfolios', 'Publish and unpublish portfolio items'), ('View media', 'media.read', 'media', 'View uploaded media'), ('Upload media', 'media.create', 'media', 'Upload images and videos'), ('Update media', 'media.update', 'media', 'Edit media metadata'), ('Delete media', 'media.delete', 'media', 'Delete media files'), ('View users', 'users.read', 'users', 'View user accounts'), ('Manage users', 'users.manage', 'users', 'Create and manage user accounts'), ('Manage roles', 'roles.manage', 'users', 'Assign roles and permissions'); INSERT INTO roles (name, slug, description, is_system) VALUES ('Owner', 'owner', 'Full access to everything', TRUE), ('Administrator', 'admin', 'Manage content, media, and settings', TRUE), ('Editor', 'editor', 'Create and edit content', TRUE), ('Viewer', 'viewer', 'Read-only access', TRUE); INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.slug = 'owner'; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.slug <> 'roles.manage' WHERE r.slug = 'admin'; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.slug IN ( 'business.read', 'products.read', 'products.create', 'products.update', 'products.publish', 'blogs.read', 'blogs.create', 'blogs.update', 'blogs.publish', 'portfolios.read', 'portfolios.create', 'portfolios.update', 'portfolios.publish', 'media.read', 'media.create', 'media.update' ) WHERE r.slug = 'editor'; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.slug IN ( 'business.read', 'domains.read', 'products.read', 'blogs.read', 'portfolios.read', 'media.read' ) WHERE r.slug = 'viewer';