Initial commit: Meshkee CMS API

NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MIGRATIONS_DIR="$ROOT_DIR/database/migrations"
CONTAINER="${POSTGRES_CONTAINER:-meshkee-postgres}"
DB_USER="${POSTGRES_USER:-meshkee}"
DB_NAME="${POSTGRES_DB:-meshkee_cms}"
"$ROOT_DIR/database/wait-for-postgres.sh"
run_migration() {
local file="$1"
echo "→ Running $(basename "$file")"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$file"
}
if [[ $# -gt 0 ]]; then
run_migration "$1"
else
for file in "$MIGRATIONS_DIR"/*.sql; do
[[ -f "$file" ]] || continue
run_migration "$file"
done
fi
echo "Done."
+102
View File
@@ -0,0 +1,102 @@
-- Meshkee CMS — initial schema
-- Tables: users, businesses, domains
-- Reusable trigger to keep updated_at in sync
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
cell_number VARCHAR(20) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(255),
first_name VARCHAR(100),
last_name VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
cell_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT users_cell_number_unique UNIQUE (cell_number),
CONSTRAINT users_cell_number_format CHECK (cell_number ~ '^\+[1-9]\d{6,14}$'),
CONSTRAINT users_email_format_optional CHECK (
email IS NULL OR email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
)
);
CREATE INDEX idx_users_cell_number ON users (cell_number);
CREATE INDEX idx_users_is_active ON users (is_active) WHERE is_active = TRUE;
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- businesses (created by super admin — no direct user owner column)
-- ---------------------------------------------------------------------------
CREATE TABLE businesses (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
settings JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT businesses_slug_unique UNIQUE (slug),
CONSTRAINT businesses_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$')
);
CREATE INDEX idx_businesses_is_active ON businesses (is_active) WHERE is_active = TRUE;
CREATE TRIGGER businesses_set_updated_at
BEFORE UPDATE ON businesses
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- domains (many per business)
-- ---------------------------------------------------------------------------
CREATE TABLE domains (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
host VARCHAR(253) NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMPTZ,
ssl_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT domains_host_unique UNIQUE (host),
CONSTRAINT domains_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT domains_host_format CHECK (
host ~ '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$'
OR host ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'
)
);
CREATE INDEX idx_domains_business_id ON domains (business_id);
CREATE INDEX idx_domains_host ON domains (host);
CREATE INDEX idx_domains_business_primary ON domains (business_id) WHERE is_primary = TRUE;
CREATE UNIQUE INDEX idx_domains_one_primary_per_business
ON domains (business_id)
WHERE is_primary = TRUE;
CREATE TRIGGER domains_set_updated_at
BEFORE UPDATE ON domains
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
@@ -0,0 +1,324 @@
-- 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';
+79
View File
@@ -0,0 +1,79 @@
-- Meshkee CMS — categories for products, blogs, portfolios
CREATE TABLE categories (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
entity_type media_entity_type NOT NULL,
parent_id BIGINT,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT categories_business_entity_slug_unique
UNIQUE (business_id, entity_type, slug),
CONSTRAINT categories_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT categories_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL,
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$')
);
CREATE INDEX idx_categories_business_entity
ON categories (business_id, entity_type, sort_order);
CREATE INDEX idx_categories_parent_id ON categories (parent_id);
CREATE INDEX idx_categories_active
ON categories (business_id, entity_type) WHERE is_active = TRUE;
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON categories
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE category_assignments (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
entity_type media_entity_type NOT NULL,
entity_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT category_assignments_unique
UNIQUE (category_id, entity_type, entity_id),
CONSTRAINT category_assignments_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT category_assignments_category_id_fkey
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
);
CREATE INDEX idx_category_assignments_entity
ON category_assignments (business_id, entity_type, entity_id);
CREATE INDEX idx_category_assignments_category_id
ON category_assignments (category_id);
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View categories', 'categories.read', 'categories', 'View categories'),
('Create categories', 'categories.create', 'categories', 'Create categories'),
('Update categories', 'categories.update', 'categories', 'Edit categories'),
('Delete categories', 'categories.delete', 'categories', 'Delete categories');
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'categories.%'
WHERE r.slug IN ('owner', 'admin');
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('categories.read', 'categories.create', 'categories.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 = 'categories.read'
WHERE r.slug = 'viewer';
@@ -0,0 +1,128 @@
-- Meshkee CMS — three user types: super_admin, business_owner, customer
-- Businesses are created by super admin (no longer tied 1:1 to user on register)
-- ---------------------------------------------------------------------------
-- business_users (owners/staff assigned to a business by super admin)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS business_users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
is_owner BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT business_users_business_user_unique UNIQUE (business_id, user_id),
CONSTRAINT business_users_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT business_users_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_business_users_user_id ON business_users (user_id);
CREATE INDEX IF NOT EXISTS idx_business_users_business_id ON business_users (business_id);
-- Migrate legacy businesses.user_id links (only when upgrading old databases)
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'businesses'
AND column_name = 'user_id'
) THEN
INSERT INTO business_users (business_id, user_id, is_owner)
SELECT id, user_id, TRUE
FROM businesses
WHERE user_id IS NOT NULL
ON CONFLICT (business_id, user_id) DO NOTHING;
ALTER TABLE businesses DROP CONSTRAINT IF EXISTS businesses_user_id_fkey;
ALTER TABLE businesses DROP CONSTRAINT IF EXISTS businesses_user_id_unique;
ALTER TABLE businesses DROP COLUMN user_id;
END IF;
END $$;
-- ---------------------------------------------------------------------------
-- business_customers (users who registered on a business website)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS business_customers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT business_customers_business_user_unique UNIQUE (business_id, user_id),
CONSTRAINT business_customers_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT business_customers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_business_customers_user_id ON business_customers (user_id);
CREATE INDEX IF NOT EXISTS idx_business_customers_business_id ON business_customers (business_id);
-- ---------------------------------------------------------------------------
-- roles: super_admin, business_owner, customer
-- ---------------------------------------------------------------------------
INSERT INTO roles (name, slug, description, is_system) VALUES
('Super Admin', 'super_admin', 'Full platform control — manages businesses, domains, and owners', TRUE),
('Business Owner', 'business_owner', 'Manages assigned business dashboard', TRUE),
('Customer', 'customer', 'Registered user on a business website', TRUE)
ON CONFLICT (slug) DO NOTHING;
-- Migrate legacy owner role assignments to business_owner
UPDATE user_roles ur
SET role_id = (SELECT id FROM roles WHERE slug = 'business_owner')
WHERE role_id = (SELECT id FROM roles WHERE slug = 'owner');
-- ---------------------------------------------------------------------------
-- permissions: platform-level (super admin)
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View all businesses', 'businesses.read', 'businesses', 'View all businesses'),
('Create businesses', 'businesses.create', 'businesses', 'Create new businesses'),
('Update businesses', 'businesses.update', 'businesses', 'Edit businesses'),
('Delete businesses', 'businesses.delete', 'businesses', 'Delete businesses'),
('Assign business owners', 'businesses.assign', 'businesses', 'Assign owners to businesses'),
('View all users', 'platform.users.read', 'platform', 'View all platform users'),
('Manage all users', 'platform.users.manage', 'platform', 'Create and manage platform users')
ON CONFLICT (slug) DO NOTHING;
-- super_admin: all permissions
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.slug = 'super_admin'
ON CONFLICT DO NOTHING;
-- business_owner: same as legacy owner (business + content + media + categories + domains read)
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', 'business.update',
'domains.read', 'domains.manage',
'products.read', 'products.create', 'products.update', 'products.delete', 'products.publish',
'blogs.read', 'blogs.create', 'blogs.update', 'blogs.delete', 'blogs.publish',
'portfolios.read', 'portfolios.create', 'portfolios.update', 'portfolios.delete', 'portfolios.publish',
'media.read', 'media.create', 'media.update', 'media.delete',
'categories.read', 'categories.create', 'categories.update', 'categories.delete',
'users.read'
)
WHERE r.slug = 'business_owner'
ON CONFLICT DO NOTHING;
-- customer: no CMS permissions for now (orders/favorites added later)
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View own orders', 'orders.read', 'orders', 'View own orders'),
('View own favorites', 'favorites.read', 'favorites', 'View own favorites')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('orders.read', 'favorites.read')
WHERE r.slug = 'customer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,79 @@
-- Business team: owners can add staff with limited per-business roles
-- ---------------------------------------------------------------------------
-- business_users: add role_id for staff permissions (owners use is_owner=true)
-- ---------------------------------------------------------------------------
ALTER TABLE business_users
ADD COLUMN IF NOT EXISTS role_id BIGINT,
ADD COLUMN IF NOT EXISTS invited_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE business_users DROP CONSTRAINT IF EXISTS business_users_role_id_fkey;
ALTER TABLE business_users
ADD CONSTRAINT business_users_role_id_fkey
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE RESTRICT;
ALTER TABLE business_users DROP CONSTRAINT IF EXISTS business_users_invited_by_fkey;
ALTER TABLE business_users
ADD CONSTRAINT business_users_invited_by_fkey
FOREIGN KEY (invited_by) REFERENCES users (id) ON DELETE SET NULL;
ALTER TABLE business_users DROP CONSTRAINT IF EXISTS business_users_member_role_check;
ALTER TABLE business_users
ADD CONSTRAINT business_users_member_role_check CHECK (
(is_owner = TRUE AND role_id IS NULL)
OR (is_owner = FALSE AND role_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_business_users_role_id ON business_users (role_id);
DROP TRIGGER IF EXISTS business_users_set_updated_at ON business_users;
CREATE TRIGGER business_users_set_updated_at
BEFORE UPDATE ON business_users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- business_staff global role (dashboard access for invited team members)
-- ---------------------------------------------------------------------------
INSERT INTO roles (name, slug, description, is_system) VALUES
('Business Staff', 'business_staff', 'Team member on a business with limited permissions', TRUE)
ON CONFLICT (slug) DO NOTHING;
-- ---------------------------------------------------------------------------
-- team management permissions (for business owners)
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View business team', 'business.team.read', 'business_team', 'View team members of a business'),
('Invite business team', 'business.team.invite', 'business_team', 'Add team members to a business'),
('Update business team', 'business.team.update', 'business_team', 'Change team member roles'),
('Remove business team', 'business.team.remove', 'business_team', 'Remove team members from a business')
ON CONFLICT (slug) DO NOTHING;
-- business_owner gets team management permissions
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'business.team.%'
WHERE r.slug = 'business_owner'
ON CONFLICT DO NOTHING;
-- admin staff role: almost full business access + team read (not invite/remove owners)
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', 'business.update',
'domains.read',
'products.read', 'products.create', 'products.update', 'products.delete', 'products.publish',
'blogs.read', 'blogs.create', 'blogs.update', 'blogs.delete', 'blogs.publish',
'portfolios.read', 'portfolios.create', 'portfolios.update', 'portfolios.delete', 'portfolios.publish',
'media.read', 'media.create', 'media.update', 'media.delete',
'categories.read', 'categories.create', 'categories.update', 'categories.delete',
'business.team.read'
)
WHERE r.slug = 'admin'
ON CONFLICT DO NOTHING;
-- editor & viewer already seeded in 002 — ensure business_staff has no extra perms
-- business_staff global role: no permissions (permissions come from business_users.role_id)
@@ -0,0 +1,75 @@
-- System-wide business categories (not scoped to any business)
-- Businesses are tagged with one or more of these categories
-- ---------------------------------------------------------------------------
-- business_categories (platform / system level)
-- ---------------------------------------------------------------------------
CREATE TABLE business_categories (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
parent_id BIGINT,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(100),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT business_categories_slug_unique UNIQUE (slug),
CONSTRAINT business_categories_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES business_categories (id) ON DELETE SET NULL,
CONSTRAINT business_categories_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$')
);
CREATE INDEX idx_business_categories_parent_id ON business_categories (parent_id);
CREATE INDEX idx_business_categories_sort_order ON business_categories (sort_order);
CREATE INDEX idx_business_categories_active
ON business_categories (is_active) WHERE is_active = TRUE;
CREATE TRIGGER business_categories_set_updated_at
BEFORE UPDATE ON business_categories
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- business_category_assignments (business ↔ system category, many-to-many)
-- ---------------------------------------------------------------------------
CREATE TABLE business_category_assignments (
business_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (business_id, category_id),
CONSTRAINT business_category_assignments_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT business_category_assignments_category_id_fkey
FOREIGN KEY (category_id) REFERENCES business_categories (id) ON DELETE CASCADE
);
CREATE INDEX idx_business_category_assignments_category_id
ON business_category_assignments (category_id);
-- ---------------------------------------------------------------------------
-- permissions (super admin manages business categories)
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View business categories', 'business_categories.read', 'business_categories', 'View system business categories'),
('Create business categories', 'business_categories.create', 'business_categories', 'Create system business categories'),
('Update business categories', 'business_categories.update', 'business_categories', 'Edit system business categories'),
('Delete business categories', 'business_categories.delete', 'business_categories', 'Delete system business categories')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'business_categories.%'
WHERE r.slug = 'super_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.slug = 'business_categories.read'
WHERE r.slug IN ('business_owner', 'admin')
ON CONFLICT DO NOTHING;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS profile JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,9 @@
-- Business i18n fields: name_fa, about (English name uses existing `name` column)
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255),
ADD COLUMN IF NOT EXISTS about TEXT;
UPDATE businesses
SET name_fa = COALESCE(name_fa, name)
WHERE name_fa IS NULL;
@@ -0,0 +1,9 @@
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
CREATE INDEX IF NOT EXISTS idx_domains_is_active ON domains (is_active);
UPDATE domains
SET expires_at = NOW() + INTERVAL '365 days'
WHERE expires_at IS NULL;
@@ -0,0 +1,7 @@
-- Remove redundant name_en (use name for English/default name)
UPDATE businesses
SET name = COALESCE(name, name_en)
WHERE name IS NULL AND name_en IS NOT NULL;
ALTER TABLE businesses DROP COLUMN IF EXISTS name_en;
@@ -0,0 +1,4 @@
-- Persian display name for product/blog/portfolio categories
ALTER TABLE categories
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255);
@@ -0,0 +1,104 @@
-- Category variations & options (product categories only)
-- Variation types: color (predefined palette), size (user-defined), custom (user-defined name + values)
CREATE TYPE variation_type AS ENUM ('color', 'size', 'custom');
CREATE TABLE category_variations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
variation_type variation_type NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT category_variations_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT category_variations_category_id_fkey
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX category_variations_one_color_per_category
ON category_variations (category_id) WHERE variation_type = 'color';
CREATE UNIQUE INDEX category_variations_one_size_per_category
ON category_variations (category_id) WHERE variation_type = 'size';
CREATE UNIQUE INDEX category_variations_custom_name_per_category
ON category_variations (category_id, name) WHERE variation_type = 'custom';
CREATE INDEX idx_category_variations_category_id ON category_variations (category_id);
CREATE INDEX idx_category_variations_business_id ON category_variations (business_id);
CREATE TRIGGER category_variations_set_updated_at
BEFORE UPDATE ON category_variations
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE category_variation_options (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
variation_id BIGINT NOT NULL,
label VARCHAR(255) NOT NULL,
value VARCHAR(255) NOT NULL,
color_hex VARCHAR(7),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT category_variation_options_variation_id_fkey
FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE CASCADE,
CONSTRAINT category_variation_options_unique_value
UNIQUE (variation_id, value)
);
CREATE INDEX idx_category_variation_options_variation_id
ON category_variation_options (variation_id);
-- Product variants (combinations of category variation options)
CREATE TABLE product_variants (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
sku VARCHAR(100),
price NUMERIC(12, 2),
compare_at_price NUMERIC(12, 2),
stock_quantity INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT product_variants_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT product_variants_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT product_variants_stock_non_negative
CHECK (stock_quantity IS NULL OR stock_quantity >= 0)
);
CREATE INDEX idx_product_variants_product_id ON product_variants (product_id);
CREATE INDEX idx_product_variants_business_id ON product_variants (business_id);
CREATE TRIGGER product_variants_set_updated_at
BEFORE UPDATE ON product_variants
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE product_variant_selections (
variant_id BIGINT NOT NULL,
variation_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
CONSTRAINT product_variant_selections_pkey PRIMARY KEY (variant_id, variation_id),
CONSTRAINT product_variant_selections_variant_id_fkey
FOREIGN KEY (variant_id) REFERENCES product_variants (id) ON DELETE CASCADE,
CONSTRAINT product_variant_selections_variation_id_fkey
FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT,
CONSTRAINT product_variant_selections_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT,
CONSTRAINT product_variant_selections_unique_option
UNIQUE (variant_id, option_id)
);
CREATE INDEX idx_product_variant_selections_option_id
ON product_variant_selections (option_id);
@@ -0,0 +1,118 @@
-- Category technical forms: dynamic form definitions per product category
-- Field types: text, textarea, select, multi_select
CREATE TYPE technical_field_type AS ENUM ('text', 'textarea', 'select', 'multi_select');
CREATE TABLE category_technical_forms (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT category_technical_forms_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT category_technical_forms_category_id_fkey
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE,
CONSTRAINT category_technical_forms_category_unique
UNIQUE (category_id)
);
CREATE INDEX idx_category_technical_forms_business_id
ON category_technical_forms (business_id);
CREATE TRIGGER category_technical_forms_set_updated_at
BEFORE UPDATE ON category_technical_forms
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE category_technical_form_fields (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
form_id BIGINT NOT NULL,
label VARCHAR(255) NOT NULL,
field_key VARCHAR(255) NOT NULL,
field_type technical_field_type NOT NULL,
is_required BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT category_technical_form_fields_form_id_fkey
FOREIGN KEY (form_id) REFERENCES category_technical_forms (id) ON DELETE CASCADE,
CONSTRAINT category_technical_form_fields_unique_key
UNIQUE (form_id, field_key)
);
CREATE INDEX idx_category_technical_form_fields_form_id
ON category_technical_form_fields (form_id);
CREATE TRIGGER category_technical_form_fields_set_updated_at
BEFORE UPDATE ON category_technical_form_fields
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE category_technical_form_field_options (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
field_id BIGINT NOT NULL,
label VARCHAR(255) NOT NULL,
value VARCHAR(255) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT category_technical_form_field_options_field_id_fkey
FOREIGN KEY (field_id) REFERENCES category_technical_form_fields (id) ON DELETE CASCADE,
CONSTRAINT category_technical_form_field_options_unique_value
UNIQUE (field_id, value)
);
CREATE INDEX idx_category_technical_form_field_options_field_id
ON category_technical_form_field_options (field_id);
-- Product technical data values (one row per product per field)
CREATE TABLE product_technical_field_values (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
field_id BIGINT NOT NULL,
text_value TEXT,
option_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT product_technical_field_values_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT product_technical_field_values_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT product_technical_field_values_field_id_fkey
FOREIGN KEY (field_id) REFERENCES category_technical_form_fields (id) ON DELETE CASCADE,
CONSTRAINT product_technical_field_values_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE SET NULL,
CONSTRAINT product_technical_field_values_unique
UNIQUE (product_id, field_id)
);
CREATE INDEX idx_product_technical_field_values_product_id
ON product_technical_field_values (product_id);
CREATE INDEX idx_product_technical_field_values_business_id
ON product_technical_field_values (business_id);
CREATE TRIGGER product_technical_field_values_set_updated_at
BEFORE UPDATE ON product_technical_field_values
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- Multi-select option selections
CREATE TABLE product_technical_field_value_options (
field_value_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
CONSTRAINT product_technical_field_value_options_pkey
PRIMARY KEY (field_value_id, option_id),
CONSTRAINT product_technical_field_value_options_field_value_id_fkey
FOREIGN KEY (field_value_id) REFERENCES product_technical_field_values (id) ON DELETE CASCADE,
CONSTRAINT product_technical_field_value_options_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE CASCADE
);
CREATE INDEX idx_product_technical_field_value_options_option_id
ON product_technical_field_value_options (option_id);
+83
View File
@@ -0,0 +1,83 @@
-- Meshkee CMS — polymorphic comments (product, blog, portfolio)
CREATE TABLE comments (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
entity_type media_entity_type NOT NULL,
entity_id BIGINT NOT NULL,
author_name VARCHAR(255) NOT NULL,
author_email VARCHAR(255),
text TEXT NOT NULL,
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
approved_at TIMESTAMPTZ,
approved_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT comments_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT comments_approved_by_fkey
FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT comments_text_nonempty CHECK (char_length(trim(text)) > 0),
CONSTRAINT comments_author_name_nonempty CHECK (char_length(trim(author_name)) > 0)
);
CREATE INDEX idx_comments_business_approval
ON comments (business_id, is_approved, created_at DESC);
CREATE INDEX idx_comments_entity
ON comments (business_id, entity_type, entity_id, is_approved, created_at DESC);
CREATE TRIGGER comments_set_updated_at
BEFORE UPDATE ON comments
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- permissions
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View comments', 'comments.read', 'comments', 'View comments on business content'),
('Approve comments', 'comments.approve', 'comments', 'Approve or reject comments'),
('Delete comments', 'comments.delete', 'comments', 'Delete comments')
ON CONFLICT (slug) DO NOTHING;
-- business_owner
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'comments.%'
WHERE r.slug = 'business_owner'
ON CONFLICT DO NOTHING;
-- owner (legacy global role)
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'comments.%'
WHERE r.slug = 'owner'
ON CONFLICT DO NOTHING;
-- admin
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('comments.read', 'comments.approve', 'comments.delete')
WHERE r.slug = 'admin'
ON CONFLICT DO NOTHING;
-- editor: read + approve (moderate), no delete
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('comments.read', 'comments.approve')
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
-- viewer: read only
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'comments.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,83 @@
-- Meshkee CMS — expert product reviews
CREATE TABLE expert_reviews (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
author_name VARCHAR(255) NOT NULL,
author_email VARCHAR(255),
rate SMALLINT NOT NULL,
positive_points TEXT[] NOT NULL DEFAULT '{}',
negative_points TEXT[] NOT NULL DEFAULT '{}',
text TEXT NOT NULL,
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
approved_at TIMESTAMPTZ,
approved_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT expert_reviews_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT expert_reviews_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT expert_reviews_approved_by_fkey
FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT expert_reviews_rate_range CHECK (rate >= 1 AND rate <= 10),
CONSTRAINT expert_reviews_text_nonempty CHECK (char_length(trim(text)) > 0),
CONSTRAINT expert_reviews_author_name_nonempty CHECK (char_length(trim(author_name)) > 0)
);
CREATE INDEX idx_expert_reviews_business_approval
ON expert_reviews (business_id, is_approved, created_at DESC);
CREATE INDEX idx_expert_reviews_product
ON expert_reviews (business_id, product_id, is_approved, created_at DESC);
CREATE TRIGGER expert_reviews_set_updated_at
BEFORE UPDATE ON expert_reviews
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- permissions
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View expert reviews', 'expert_reviews.read', 'expert_reviews', 'View expert product reviews'),
('Approve expert reviews', 'expert_reviews.approve', 'expert_reviews', 'Approve or reject expert reviews'),
('Delete expert reviews', 'expert_reviews.delete', 'expert_reviews', 'Delete expert reviews')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'expert_reviews.%'
WHERE r.slug = 'business_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.slug LIKE 'expert_reviews.%'
WHERE r.slug = '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.slug IN ('expert_reviews.read', 'expert_reviews.approve', 'expert_reviews.delete')
WHERE r.slug = '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.slug IN ('expert_reviews.read', 'expert_reviews.approve')
WHERE r.slug = '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.slug = 'expert_reviews.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
+38
View File
@@ -0,0 +1,38 @@
-- Meshkee CMS — addresses owned by exactly one user or business
CREATE TABLE addresses (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT,
business_id BIGINT,
province VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
address TEXT NOT NULL,
postal_code VARCHAR(20) NOT NULL,
landline VARCHAR(30),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT addresses_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT addresses_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT addresses_exactly_one_owner CHECK (
(user_id IS NOT NULL AND business_id IS NULL)
OR (user_id IS NULL AND business_id IS NOT NULL)
),
CONSTRAINT addresses_province_nonempty CHECK (char_length(trim(province)) > 0),
CONSTRAINT addresses_city_nonempty CHECK (char_length(trim(city)) > 0),
CONSTRAINT addresses_address_nonempty CHECK (char_length(trim(address)) > 0),
CONSTRAINT addresses_postal_code_nonempty CHECK (char_length(trim(postal_code)) > 0),
CONSTRAINT addresses_landline_nonempty_optional CHECK (
landline IS NULL OR char_length(trim(landline)) > 0
)
);
CREATE INDEX idx_addresses_user_id ON addresses (user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_addresses_business_id ON addresses (business_id) WHERE business_id IS NOT NULL;
CREATE TRIGGER addresses_set_updated_at
BEFORE UPDATE ON addresses
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
@@ -0,0 +1,12 @@
-- Business profile fields for dashboard / public storefront
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS vision TEXT,
ADD COLUMN IF NOT EXISTS emails JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS phone_numbers JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS social_media JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS logo_media_id BIGINT;
ALTER TABLE businesses
ADD CONSTRAINT businesses_logo_media_id_fkey
FOREIGN KEY (logo_media_id) REFERENCES media (id) ON DELETE SET NULL;
+73
View File
@@ -0,0 +1,73 @@
-- Meshkee CMS — location reference tree (country → province → city)
CREATE TYPE city_level AS ENUM ('country', 'province', 'city');
CREATE TABLE cities (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
parent_id BIGINT,
level city_level NOT NULL,
name_fa VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NOT NULL,
landline_code VARCHAR(10),
slug VARCHAR(100) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT cities_slug_unique UNIQUE (slug),
CONSTRAINT cities_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES cities (id) ON DELETE CASCADE,
CONSTRAINT cities_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
CONSTRAINT cities_name_fa_nonempty CHECK (char_length(trim(name_fa)) > 0),
CONSTRAINT cities_name_en_nonempty CHECK (char_length(trim(name_en)) > 0),
CONSTRAINT cities_landline_code_nonempty_optional CHECK (
landline_code IS NULL OR char_length(trim(landline_code)) > 0
),
CONSTRAINT cities_country_root CHECK (
(level = 'country' AND parent_id IS NULL)
OR (level <> 'country' AND parent_id IS NOT NULL)
)
);
CREATE INDEX idx_cities_parent_id ON cities (parent_id);
CREATE INDEX idx_cities_level ON cities (level);
CREATE INDEX idx_cities_level_parent ON cities (level, parent_id, sort_order);
CREATE INDEX idx_cities_active ON cities (is_active) WHERE is_active = TRUE;
CREATE TRIGGER cities_set_updated_at
BEFORE UPDATE ON cities
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE OR REPLACE FUNCTION cities_validate_parent_level()
RETURNS TRIGGER AS $$
DECLARE
parent_level city_level;
BEGIN
IF NEW.level = 'country' THEN
RETURN NEW;
END IF;
SELECT level INTO parent_level FROM cities WHERE id = NEW.parent_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'parent city not found';
END IF;
IF NEW.level = 'province' AND parent_level <> 'country' THEN
RAISE EXCEPTION 'province parent must be a country';
END IF;
IF NEW.level = 'city' AND parent_level <> 'province' THEN
RAISE EXCEPTION 'city parent must be a province';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER cities_validate_parent_level
BEFORE INSERT OR UPDATE ON cities
FOR EACH ROW
EXECUTE FUNCTION cities_validate_parent_level();
@@ -0,0 +1,20 @@
-- Product-level variation value selections (which category options apply to a product).
-- Store item variants are created later from these values.
CREATE TABLE product_variation_values (
product_id BIGINT NOT NULL,
variation_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT product_variation_values_pkey PRIMARY KEY (product_id, option_id),
CONSTRAINT product_variation_values_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT product_variation_values_variation_id_fkey
FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT,
CONSTRAINT product_variation_values_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT
);
CREATE INDEX idx_product_variation_values_variation_id
ON product_variation_values (variation_id);
@@ -0,0 +1,3 @@
-- Festival flag for store item variants
ALTER TABLE product_variants
ADD COLUMN is_festival BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,7 @@
-- Reward points earned when purchasing a store item variant (festival rewards — usage later)
ALTER TABLE product_variants
ADD COLUMN reward_points INTEGER;
ALTER TABLE product_variants
ADD CONSTRAINT product_variants_reward_points_non_negative
CHECK (reward_points IS NULL OR reward_points >= 0);
+205
View File
@@ -0,0 +1,205 @@
-- Meshkee CMS — shopping cart and orders
-- ---------------------------------------------------------------------------
-- enums
-- ---------------------------------------------------------------------------
DO $$ BEGIN
CREATE TYPE order_status AS ENUM (
'pending',
'confirmed',
'processing',
'shipped',
'delivered',
'cancelled'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE order_source AS ENUM ('website', 'admin');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ---------------------------------------------------------------------------
-- carts (one per customer per business)
-- ---------------------------------------------------------------------------
CREATE TABLE carts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT carts_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT carts_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT carts_business_user_unique UNIQUE (business_id, user_id)
);
CREATE INDEX idx_carts_business_id ON carts (business_id);
CREATE INDEX idx_carts_user_id ON carts (user_id);
CREATE TRIGGER carts_set_updated_at
BEFORE UPDATE ON carts
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- cart items (product variants in cart)
-- ---------------------------------------------------------------------------
CREATE TABLE cart_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
cart_id BIGINT NOT NULL,
variant_id BIGINT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT cart_items_cart_id_fkey
FOREIGN KEY (cart_id) REFERENCES carts (id) ON DELETE CASCADE,
CONSTRAINT cart_items_variant_id_fkey
FOREIGN KEY (variant_id) REFERENCES product_variants (id) ON DELETE CASCADE,
CONSTRAINT cart_items_cart_variant_unique UNIQUE (cart_id, variant_id),
CONSTRAINT cart_items_quantity_positive CHECK (quantity > 0)
);
CREATE INDEX idx_cart_items_cart_id ON cart_items (cart_id);
CREATE INDEX idx_cart_items_variant_id ON cart_items (variant_id);
CREATE TRIGGER cart_items_set_updated_at
BEFORE UPDATE ON cart_items
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- orders
-- ---------------------------------------------------------------------------
CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
order_number VARCHAR(30) NOT NULL,
status order_status NOT NULL DEFAULT 'pending',
source order_source NOT NULL DEFAULT 'website',
subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0,
shipping_total NUMERIC(12, 2) NOT NULL DEFAULT 0,
discount_total NUMERIC(12, 2) NOT NULL DEFAULT 0,
total NUMERIC(12, 2) NOT NULL DEFAULT 0,
shipping_address JSONB NOT NULL DEFAULT '{}',
address_id BIGINT,
customer_notes TEXT,
admin_notes TEXT,
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT orders_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT orders_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
CONSTRAINT orders_address_id_fkey
FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE SET NULL,
CONSTRAINT orders_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT orders_business_order_number_unique UNIQUE (business_id, order_number),
CONSTRAINT orders_subtotal_non_negative CHECK (subtotal >= 0),
CONSTRAINT orders_shipping_total_non_negative CHECK (shipping_total >= 0),
CONSTRAINT orders_discount_total_non_negative CHECK (discount_total >= 0),
CONSTRAINT orders_total_non_negative CHECK (total >= 0)
);
CREATE INDEX idx_orders_business_created
ON orders (business_id, created_at DESC);
CREATE INDEX idx_orders_business_user
ON orders (business_id, user_id, created_at DESC);
CREATE INDEX idx_orders_business_status
ON orders (business_id, status, created_at DESC);
CREATE TRIGGER orders_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- order items (line items with price snapshots)
-- ---------------------------------------------------------------------------
CREATE TABLE order_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
variant_id BIGINT,
product_id BIGINT NOT NULL,
product_title VARCHAR(255) NOT NULL,
variant_sku VARCHAR(100),
unit_price NUMERIC(12, 2) NOT NULL,
compare_at_price NUMERIC(12, 2),
quantity INT NOT NULL,
line_total NUMERIC(12, 2) NOT NULL,
selections_snapshot JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT order_items_order_id_fkey
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
CONSTRAINT order_items_variant_id_fkey
FOREIGN KEY (variant_id) REFERENCES product_variants (id) ON DELETE SET NULL,
CONSTRAINT order_items_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE RESTRICT,
CONSTRAINT order_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT order_items_unit_price_non_negative CHECK (unit_price >= 0),
CONSTRAINT order_items_line_total_non_negative CHECK (line_total >= 0)
);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
CREATE INDEX idx_order_items_variant_id ON order_items (variant_id);
-- ---------------------------------------------------------------------------
-- permissions
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('Create orders', 'orders.create', 'orders', 'Create orders on behalf of customers'),
('Update orders', 'orders.update', 'orders', 'Update order status and admin notes')
ON CONFLICT (slug) DO NOTHING;
-- business_owner
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('orders.read', 'orders.create', 'orders.update')
WHERE r.slug = 'business_owner'
ON CONFLICT DO NOTHING;
-- owner (legacy global role)
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('orders.read', 'orders.create', 'orders.update')
WHERE r.slug = 'owner'
ON CONFLICT DO NOTHING;
-- admin
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('orders.read', 'orders.create', 'orders.update')
WHERE r.slug = 'admin'
ON CONFLICT DO NOTHING;
-- editor: read + update status
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('orders.read', 'orders.update')
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
-- viewer: read only
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'orders.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,198 @@
-- Meshkee CMS — store items (one per product) and store item variants (purchasable SKUs)
-- Replaces product_variants / product_variant_selections
-- ---------------------------------------------------------------------------
-- store_items (one listing per product in the shop)
-- ---------------------------------------------------------------------------
CREATE TABLE store_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT store_items_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT store_items_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT store_items_business_product_unique UNIQUE (business_id, product_id)
);
CREATE INDEX idx_store_items_business_id ON store_items (business_id);
CREATE INDEX idx_store_items_product_id ON store_items (product_id);
CREATE TRIGGER store_items_set_updated_at
BEFORE UPDATE ON store_items
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- store_item_variants (purchasable combinations with price & stock)
-- ---------------------------------------------------------------------------
CREATE TABLE store_item_variants (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
store_item_id BIGINT NOT NULL,
business_id BIGINT NOT NULL,
sku VARCHAR(100),
price NUMERIC(12, 2),
compare_at_price NUMERIC(12, 2),
stock_quantity INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_festival BOOLEAN NOT NULL DEFAULT FALSE,
reward_points INTEGER,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
legacy_product_variant_id BIGINT,
CONSTRAINT store_item_variants_store_item_id_fkey
FOREIGN KEY (store_item_id) REFERENCES store_items (id) ON DELETE CASCADE,
CONSTRAINT store_item_variants_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT store_item_variants_stock_non_negative
CHECK (stock_quantity IS NULL OR stock_quantity >= 0),
CONSTRAINT store_item_variants_reward_points_non_negative
CHECK (reward_points IS NULL OR reward_points >= 0)
);
CREATE INDEX idx_store_item_variants_store_item_id ON store_item_variants (store_item_id);
CREATE INDEX idx_store_item_variants_business_id ON store_item_variants (business_id);
CREATE UNIQUE INDEX idx_store_item_variants_legacy_id
ON store_item_variants (legacy_product_variant_id)
WHERE legacy_product_variant_id IS NOT NULL;
CREATE TRIGGER store_item_variants_set_updated_at
BEFORE UPDATE ON store_item_variants
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- store_item_variant_selections
-- ---------------------------------------------------------------------------
CREATE TABLE store_item_variant_selections (
variant_id BIGINT NOT NULL,
variation_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
CONSTRAINT store_item_variant_selections_pkey PRIMARY KEY (variant_id, variation_id),
CONSTRAINT store_item_variant_selections_variant_id_fkey
FOREIGN KEY (variant_id) REFERENCES store_item_variants (id) ON DELETE CASCADE,
CONSTRAINT store_item_variant_selections_variation_id_fkey
FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT,
CONSTRAINT store_item_variant_selections_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT,
CONSTRAINT store_item_variant_selections_unique_option
UNIQUE (variant_id, option_id)
);
CREATE INDEX idx_store_item_variant_selections_option_id
ON store_item_variant_selections (option_id);
-- ---------------------------------------------------------------------------
-- migrate product_variants → store_items + store_item_variants
-- ---------------------------------------------------------------------------
INSERT INTO store_items (business_id, product_id, is_active, sort_order, created_at, updated_at)
SELECT DISTINCT
pv.business_id,
pv.product_id,
TRUE,
0,
NOW(),
NOW()
FROM product_variants pv;
INSERT INTO store_item_variants (
store_item_id,
business_id,
sku,
price,
compare_at_price,
stock_quantity,
is_active,
is_festival,
reward_points,
sort_order,
created_at,
updated_at,
legacy_product_variant_id
)
SELECT
si.id,
pv.business_id,
pv.sku,
pv.price,
pv.compare_at_price,
pv.stock_quantity,
pv.is_active,
pv.is_festival,
pv.reward_points,
pv.sort_order,
pv.created_at,
pv.updated_at,
pv.id
FROM product_variants pv
JOIN store_items si
ON si.business_id = pv.business_id
AND si.product_id = pv.product_id;
INSERT INTO store_item_variant_selections (variant_id, variation_id, option_id)
SELECT
siv.id,
pvs.variation_id,
pvs.option_id
FROM product_variant_selections pvs
JOIN store_item_variants siv
ON siv.legacy_product_variant_id = pvs.variant_id;
-- ---------------------------------------------------------------------------
-- repoint cart_items and order_items to store_item_variants
-- ---------------------------------------------------------------------------
ALTER TABLE cart_items DROP CONSTRAINT cart_items_variant_id_fkey;
ALTER TABLE cart_items RENAME COLUMN variant_id TO store_item_variant_id;
UPDATE cart_items ci
SET store_item_variant_id = siv.id
FROM store_item_variants siv
WHERE siv.legacy_product_variant_id = ci.store_item_variant_id;
ALTER TABLE cart_items
ADD CONSTRAINT cart_items_store_item_variant_id_fkey
FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE CASCADE;
ALTER TABLE cart_items
DROP CONSTRAINT IF EXISTS cart_items_cart_variant_unique;
ALTER TABLE cart_items
ADD CONSTRAINT cart_items_cart_store_item_variant_unique
UNIQUE (cart_id, store_item_variant_id);
DROP INDEX IF EXISTS idx_cart_items_variant_id;
CREATE INDEX idx_cart_items_store_item_variant_id
ON cart_items (store_item_variant_id);
ALTER TABLE order_items DROP CONSTRAINT order_items_variant_id_fkey;
ALTER TABLE order_items RENAME COLUMN variant_id TO store_item_variant_id;
UPDATE order_items oi
SET store_item_variant_id = siv.id
FROM store_item_variants siv
WHERE siv.legacy_product_variant_id = oi.store_item_variant_id;
ALTER TABLE order_items
ADD CONSTRAINT order_items_store_item_variant_id_fkey
FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE SET NULL;
DROP INDEX IF EXISTS idx_order_items_variant_id;
CREATE INDEX idx_order_items_store_item_variant_id
ON order_items (store_item_variant_id);
-- ---------------------------------------------------------------------------
-- drop legacy tables
-- ---------------------------------------------------------------------------
DROP TABLE product_variant_selections;
DROP TABLE product_variants;
ALTER TABLE store_item_variants DROP COLUMN legacy_product_variant_id;
DROP INDEX IF EXISTS idx_store_item_variants_legacy_id;
@@ -0,0 +1,6 @@
-- Business-scoped customer enable/disable (does not deactivate the global user account)
ALTER TABLE business_customers
ADD COLUMN IF NOT EXISTS is_enabled BOOLEAN NOT NULL DEFAULT TRUE;
CREATE INDEX IF NOT EXISTS idx_business_customers_is_enabled
ON business_customers (business_id, is_enabled);
+114
View File
@@ -0,0 +1,114 @@
-- Meshkee CMS — payment transactions
-- ---------------------------------------------------------------------------
-- enums
-- ---------------------------------------------------------------------------
DO $$ BEGIN
CREATE TYPE transaction_type AS ENUM (
'pos',
'cash',
'transfer',
'e_payment_gate'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE transaction_status AS ENUM (
'pending',
'completed',
'failed',
'refunded'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ---------------------------------------------------------------------------
-- transactions
-- ---------------------------------------------------------------------------
CREATE TABLE transactions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
order_id BIGINT,
user_id BIGINT NOT NULL,
type transaction_type NOT NULL,
status transaction_status NOT NULL DEFAULT 'pending',
amount NUMERIC(12, 2) NOT NULL,
pos_type VARCHAR(100),
gateway_type VARCHAR(100),
transfer_account VARCHAR(255),
transfer_ref_number VARCHAR(100),
notes TEXT,
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT transactions_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT transactions_order_id_fkey
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE SET NULL,
CONSTRAINT transactions_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
CONSTRAINT transactions_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT transactions_amount_non_negative CHECK (amount >= 0),
CONSTRAINT transactions_type_fields_check CHECK (
(type = 'pos'
AND pos_type IS NOT NULL
AND gateway_type IS NULL
AND transfer_account IS NULL
AND transfer_ref_number IS NULL)
OR (type = 'cash'
AND pos_type IS NULL
AND gateway_type IS NULL
AND transfer_account IS NULL
AND transfer_ref_number IS NULL)
OR (type = 'transfer'
AND transfer_account IS NOT NULL
AND transfer_ref_number IS NOT NULL
AND pos_type IS NULL
AND gateway_type IS NULL)
OR (type = 'e_payment_gate'
AND gateway_type IS NOT NULL
AND pos_type IS NULL
AND transfer_account IS NULL
AND transfer_ref_number IS NULL)
)
);
CREATE INDEX idx_transactions_business_created
ON transactions (business_id, created_at DESC);
CREATE INDEX idx_transactions_order_id
ON transactions (order_id);
CREATE INDEX idx_transactions_business_user
ON transactions (business_id, user_id, created_at DESC);
CREATE TRIGGER transactions_set_updated_at
BEFORE UPDATE ON transactions
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- permissions
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View transactions', 'transactions.read', 'transactions', 'View payment transactions')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'transactions.read'
WHERE r.slug IN ('business_owner', 'owner', '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.slug = 'transactions.read'
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,7 @@
-- Meshkee CMS — order fulfillment process step (from business store settings)
ALTER TABLE orders
ADD COLUMN IF NOT EXISTS process_step_id VARCHAR(64) NOT NULL DEFAULT 'processing';
CREATE INDEX IF NOT EXISTS idx_orders_business_process_step
ON orders (business_id, process_step_id);
@@ -0,0 +1,63 @@
-- Meshkee CMS — saved operator shopping cards (draft orders)
CREATE TABLE shopping_cards (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0,
total NUMERIC(12, 2) NOT NULL DEFAULT 0,
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT shopping_cards_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT shopping_cards_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
CONSTRAINT shopping_cards_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT shopping_cards_subtotal_non_negative CHECK (subtotal >= 0),
CONSTRAINT shopping_cards_total_non_negative CHECK (total >= 0)
);
CREATE INDEX idx_shopping_cards_business_created
ON shopping_cards (business_id, created_at DESC);
CREATE INDEX idx_shopping_cards_business_user
ON shopping_cards (business_id, user_id, created_at DESC);
CREATE TRIGGER shopping_cards_set_updated_at
BEFORE UPDATE ON shopping_cards
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE shopping_card_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
shopping_card_id BIGINT NOT NULL,
store_item_variant_id BIGINT,
product_id BIGINT NOT NULL,
product_title VARCHAR(255) NOT NULL,
variant_sku VARCHAR(100),
unit_price NUMERIC(12, 2) NOT NULL,
compare_at_price NUMERIC(12, 2),
quantity INT NOT NULL,
line_total NUMERIC(12, 2) NOT NULL,
selections_snapshot JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT shopping_card_items_card_id_fkey
FOREIGN KEY (shopping_card_id) REFERENCES shopping_cards (id) ON DELETE CASCADE,
CONSTRAINT shopping_card_items_variant_id_fkey
FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE SET NULL,
CONSTRAINT shopping_card_items_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE RESTRICT,
CONSTRAINT shopping_card_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT shopping_card_items_unit_price_non_negative CHECK (unit_price >= 0),
CONSTRAINT shopping_card_items_line_total_non_negative CHECK (line_total >= 0)
);
CREATE INDEX idx_shopping_card_items_card_id
ON shopping_card_items (shopping_card_id);
CREATE INDEX idx_shopping_card_items_variant_id
ON shopping_card_items (store_item_variant_id);
@@ -0,0 +1,9 @@
-- Meshkee CMS — blog post type (news | article | blog)
CREATE TYPE blog_post_type AS ENUM ('news', 'article', 'blog');
ALTER TABLE blogs
ADD COLUMN post_type blog_post_type NOT NULL DEFAULT 'blog';
CREATE INDEX idx_blogs_business_post_type
ON blogs (business_id, post_type);
@@ -0,0 +1,37 @@
-- Meshkee CMS — curated store specials (e.g. special sale, best sellers)
CREATE TABLE store_specials (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT store_specials_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE
);
CREATE INDEX idx_store_specials_business_id ON store_specials (business_id);
CREATE TRIGGER store_specials_set_updated_at
BEFORE UPDATE ON store_specials
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE store_special_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
special_id BIGINT NOT NULL,
store_item_id BIGINT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
CONSTRAINT store_special_items_special_id_fkey
FOREIGN KEY (special_id) REFERENCES store_specials (id) ON DELETE CASCADE,
CONSTRAINT store_special_items_store_item_id_fkey
FOREIGN KEY (store_item_id) REFERENCES store_items (id) ON DELETE CASCADE,
CONSTRAINT store_special_items_unique UNIQUE (special_id, store_item_id)
);
CREATE INDEX idx_store_special_items_special_id ON store_special_items (special_id);
CREATE INDEX idx_store_special_items_store_item_id ON store_special_items (store_item_id);
@@ -0,0 +1,27 @@
-- Website contact form submissions (per business)
CREATE TABLE contact_submissions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
cell_number VARCHAR(20),
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT contact_submissions_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT contact_submissions_title_nonempty CHECK (char_length(trim(title)) > 0),
CONSTRAINT contact_submissions_name_nonempty CHECK (char_length(trim(name)) > 0),
CONSTRAINT contact_submissions_text_nonempty CHECK (char_length(trim(text)) > 0)
);
CREATE INDEX idx_contact_submissions_business_created
ON contact_submissions (business_id, created_at DESC);
CREATE TRIGGER contact_submissions_set_updated_at
BEFORE UPDATE ON contact_submissions
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
+42
View File
@@ -0,0 +1,42 @@
-- Customer product favorites (per business)
CREATE TABLE favorites (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT favorites_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT favorites_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT favorites_product_id_fkey
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT favorites_business_user_product_unique
UNIQUE (business_id, user_id, product_id)
);
CREATE INDEX idx_favorites_business_user_created
ON favorites (business_id, user_id, created_at DESC);
CREATE INDEX idx_favorites_product_id
ON favorites (product_id);
CREATE TRIGGER favorites_set_updated_at
BEFORE UPDATE ON favorites
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
INSERT INTO permissions (name, slug, group_name, description) VALUES
('Add own favorites', 'favorites.create', 'favorites', 'Add products to own favorites'),
('Remove own favorites', 'favorites.delete', 'favorites', 'Remove products from own favorites')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('favorites.create', 'favorites.delete')
WHERE r.slug = 'customer'
ON CONFLICT DO NOTHING;
+66
View File
@@ -0,0 +1,66 @@
-- Meshkee CMS — product brands (per business)
CREATE TABLE brands (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
name_en VARCHAR(255) NOT NULL,
name_fa VARCHAR(255),
image_media_id BIGINT,
about TEXT,
slug VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT brands_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT brands_image_media_id_fkey
FOREIGN KEY (image_media_id) REFERENCES media (id) ON DELETE SET NULL,
CONSTRAINT brands_business_slug_unique UNIQUE (business_id, slug),
CONSTRAINT brands_name_en_nonempty CHECK (char_length(trim(name_en)) > 0)
);
CREATE INDEX idx_brands_business_id ON brands (business_id);
CREATE INDEX idx_brands_image_media_id ON brands (image_media_id);
CREATE TRIGGER brands_set_updated_at
BEFORE UPDATE ON brands
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
ALTER TABLE products
ADD COLUMN brand_id BIGINT,
ADD CONSTRAINT products_brand_id_fkey
FOREIGN KEY (brand_id) REFERENCES brands (id) ON DELETE SET NULL;
CREATE INDEX idx_products_brand_id ON products (brand_id);
-- ---------------------------------------------------------------------------
-- permissions
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View brands', 'brands.read', 'brands', 'View product brands'),
('Create brands', 'brands.create', 'brands', 'Create product brands'),
('Update brands', 'brands.update', 'brands', 'Edit product brands'),
('Delete brands', 'brands.delete', 'brands', 'Delete product brands')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'brands.%'
WHERE r.slug IN ('business_owner', 'owner', '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.slug IN ('brands.read', 'brands.create', 'brands.update')
WHERE r.slug = '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.slug = 'brands.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,177 @@
-- Meshkee CMS — website homepage widgets: category/brand groups and sliders
-- ---------------------------------------------------------------------------
-- brands: user-defined list order
-- ---------------------------------------------------------------------------
ALTER TABLE brands
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_brands_business_sort_order
ON brands (business_id, sort_order);
-- ---------------------------------------------------------------------------
-- website category groups (curated category rows for the storefront)
-- ---------------------------------------------------------------------------
CREATE TABLE website_category_groups (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT website_category_groups_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE
);
CREATE INDEX idx_website_category_groups_business_id
ON website_category_groups (business_id);
CREATE TRIGGER website_category_groups_set_updated_at
BEFORE UPDATE ON website_category_groups
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE website_category_group_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
group_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
CONSTRAINT website_category_group_items_group_id_fkey
FOREIGN KEY (group_id) REFERENCES website_category_groups (id) ON DELETE CASCADE,
CONSTRAINT website_category_group_items_category_id_fkey
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE,
CONSTRAINT website_category_group_items_unique UNIQUE (group_id, category_id)
);
CREATE INDEX idx_website_category_group_items_group_id
ON website_category_group_items (group_id);
CREATE INDEX idx_website_category_group_items_category_id
ON website_category_group_items (category_id);
-- ---------------------------------------------------------------------------
-- website brand groups (curated brand rows for the storefront)
-- ---------------------------------------------------------------------------
CREATE TABLE website_brand_groups (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT website_brand_groups_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE
);
CREATE INDEX idx_website_brand_groups_business_id
ON website_brand_groups (business_id);
CREATE TRIGGER website_brand_groups_set_updated_at
BEFORE UPDATE ON website_brand_groups
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE website_brand_group_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
group_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
CONSTRAINT website_brand_group_items_group_id_fkey
FOREIGN KEY (group_id) REFERENCES website_brand_groups (id) ON DELETE CASCADE,
CONSTRAINT website_brand_group_items_brand_id_fkey
FOREIGN KEY (brand_id) REFERENCES brands (id) ON DELETE CASCADE,
CONSTRAINT website_brand_group_items_unique UNIQUE (group_id, brand_id)
);
CREATE INDEX idx_website_brand_group_items_group_id
ON website_brand_group_items (group_id);
CREATE INDEX idx_website_brand_group_items_brand_id
ON website_brand_group_items (brand_id);
-- ---------------------------------------------------------------------------
-- website sliders (multiple sliders per business, each with ordered slides)
-- ---------------------------------------------------------------------------
CREATE TABLE website_sliders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT website_sliders_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE
);
CREATE INDEX idx_website_sliders_business_id
ON website_sliders (business_id);
CREATE TRIGGER website_sliders_set_updated_at
BEFORE UPDATE ON website_sliders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE website_slider_slides (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slider_id BIGINT NOT NULL,
image_media_id BIGINT NOT NULL,
title VARCHAR(255),
link_url VARCHAR(2048),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT website_slider_slides_slider_id_fkey
FOREIGN KEY (slider_id) REFERENCES website_sliders (id) ON DELETE CASCADE,
CONSTRAINT website_slider_slides_image_media_id_fkey
FOREIGN KEY (image_media_id) REFERENCES media (id) ON DELETE RESTRICT
);
CREATE INDEX idx_website_slider_slides_slider_id
ON website_slider_slides (slider_id);
CREATE INDEX idx_website_slider_slides_image_media_id
ON website_slider_slides (image_media_id);
CREATE TRIGGER website_slider_slides_set_updated_at
BEFORE UPDATE ON website_slider_slides
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- permissions
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View website widgets', 'website.read', 'website', 'View homepage category/brand groups and sliders'),
('Manage website widgets', 'website.update', 'website', 'Create and edit homepage category/brand groups and sliders')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'website.%'
WHERE r.slug IN ('business_owner', 'owner', '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.slug IN ('website.read', 'website.update')
WHERE r.slug = '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.slug = 'website.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,4 @@
-- User-defined label for saved addresses (e.g. home, office)
ALTER TABLE addresses
ADD COLUMN IF NOT EXISTS label VARCHAR(100);
@@ -0,0 +1,7 @@
-- Postal code is optional for saved addresses
ALTER TABLE addresses
DROP CONSTRAINT IF EXISTS addresses_postal_code_nonempty;
ALTER TABLE addresses
ALTER COLUMN postal_code DROP NOT NULL;
@@ -0,0 +1,17 @@
-- Favicon generated from business logo for dashboards and storefront
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS favicon_media_id BIGINT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'businesses_favicon_media_id_fkey'
) THEN
ALTER TABLE businesses
ADD CONSTRAINT businesses_favicon_media_id_fkey
FOREIGN KEY (favicon_media_id) REFERENCES media (id) ON DELETE SET NULL;
END IF;
END $$;
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SEEDS_DIR="$ROOT_DIR/database/seeds"
CONTAINER="${POSTGRES_CONTAINER:-meshkee-postgres}"
DB_USER="${POSTGRES_USER:-meshkee}"
DB_NAME="${POSTGRES_DB:-meshkee_cms}"
"$ROOT_DIR/database/wait-for-postgres.sh"
run_seed() {
local file="$1"
echo "→ Seeding $(basename "$file")"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$file"
}
if [[ $# -gt 0 ]]; then
run_seed "$1"
else
for file in "$SEEDS_DIR"/*.sql; do
[[ -f "$file" ]] || continue
run_seed "$file"
done
fi
echo "Seed complete."
+183
View File
@@ -0,0 +1,183 @@
-- Sample seed data for local development / DataGrip testing
-- Password for all users: password
--
-- User types:
-- 1 Ali — super_admin
-- 2 Reza — business_owner (Meshkee Demo Shop)
-- 3 Sara — business_owner (Creative Studio)
-- 4 Mina — customer on shop-a.local
-- 5 Amir — customer on studio-b.local
INSERT INTO users (id, cell_number, password_hash, email, first_name, last_name, cell_verified_at) VALUES
(1, '+989121111111', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'ali@meshkee.demo', 'Ali', 'Hassani', NOW()),
(2, '+989122222222', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'reza@shop.demo', 'Reza', 'Ahmadi', NOW()),
(3, '+989123333333', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'sara@studio.demo', 'Sara', 'Karimi', NOW()),
(4, '+989124444444', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'mina@customer.demo', 'Mina', 'Salehi', NOW()),
(5, '+989125555555', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'amir@customer.demo', 'Amir', 'Jafari', NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO businesses (id, name, name_fa, about, slug, description) VALUES
(1, 'Meshkee Demo Shop', 'فروشگاه دمو مشکی', 'فروشگاه آنلاین نمونه برای تست سیستم', 'meshkee-demo-shop', 'Sample e-commerce business'),
(2, 'Creative Studio', 'استودیو خلاق', 'آژانس طراحی و برندینگ', 'creative-studio', 'Design and branding agency')
ON CONFLICT (id) DO NOTHING;
-- System business categories are seeded in 005_business_categories.sql
INSERT INTO domains (id, business_id, host, is_primary, is_verified, verified_at, ssl_enabled) VALUES
(1, 1, 'shop-a.local', TRUE, TRUE, NOW(), FALSE),
(2, 1, 'www.shop-a.local', FALSE, TRUE, NOW(), FALSE),
(3, 2, 'studio-b.local', TRUE, TRUE, NOW(), FALSE)
ON CONFLICT (id) DO NOTHING;
-- Roles
INSERT INTO user_roles (user_id, role_id)
SELECT 1, r.id FROM roles r WHERE r.slug = 'super_admin'
ON CONFLICT (user_id, role_id) DO NOTHING;
INSERT INTO user_roles (user_id, role_id)
SELECT 2, r.id FROM roles r WHERE r.slug = 'business_owner'
ON CONFLICT (user_id, role_id) DO NOTHING;
INSERT INTO user_roles (user_id, role_id)
SELECT 3, r.id FROM roles r WHERE r.slug = 'business_owner'
ON CONFLICT (user_id, role_id) DO NOTHING;
INSERT INTO user_roles (user_id, role_id)
SELECT 4, r.id FROM roles r WHERE r.slug = 'customer'
ON CONFLICT (user_id, role_id) DO NOTHING;
INSERT INTO user_roles (user_id, role_id)
SELECT 5, r.id FROM roles r WHERE r.slug = 'customer'
ON CONFLICT (user_id, role_id) DO NOTHING;
-- Business owners (assigned by super admin)
INSERT INTO business_users (id, business_id, user_id, is_owner) VALUES
(1, 1, 2, TRUE),
(2, 2, 3, TRUE)
ON CONFLICT (id) DO NOTHING;
-- Team staff: editor on business 1 (invited by business owner)
INSERT INTO users (id, cell_number, password_hash, email, first_name, last_name, cell_verified_at) VALUES
(6, '+989126666667', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'editor@shop.demo', 'Nima', 'Editori', NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO business_users (id, business_id, user_id, is_owner, role_id, invited_by)
SELECT 3, 1, 6, FALSE, r.id, 2
FROM roles r WHERE r.slug = 'editor'
ON CONFLICT (id) DO NOTHING;
INSERT INTO user_roles (user_id, role_id)
SELECT 6, r.id FROM roles r WHERE r.slug = 'business_staff'
ON CONFLICT (user_id, role_id) DO NOTHING;
-- Website customers
INSERT INTO business_customers (id, business_id, user_id) VALUES
(1, 1, 4),
(2, 2, 5)
ON CONFLICT (id) DO NOTHING;
INSERT INTO media (
id, business_id, uploaded_by, media_type, storage_disk, storage_path, public_url,
file_name, original_file_name, mime_type, file_size_bytes, width, height, duration_seconds, alt_text
) VALUES
(1, 1, 2, 'image', 'local', '/uploads/shop/hero-phone.jpg', 'https://cdn.meshkee.demo/shop/hero-phone.jpg', 'hero-phone.jpg', 'hero-phone.jpg', 'image/jpeg', 245000, 1200, 800, NULL, 'Smartphone hero'),
(2, 1, 2, 'image', 'local', '/uploads/shop/laptop.jpg', 'https://cdn.meshkee.demo/shop/laptop.jpg', 'laptop.jpg', 'laptop.jpg', 'image/jpeg', 198000, 1200, 800, NULL, 'Laptop product shot'),
(3, 1, 2, 'video', 'local', '/uploads/shop/unboxing.mp4', 'https://cdn.meshkee.demo/shop/unboxing.mp4', 'unboxing.mp4', 'unboxing.mp4', 'video/mp4', 5200000, NULL, NULL, 42.50, 'Product unboxing'),
(4, 2, 3, 'image', 'local', '/uploads/studio/brand-cover.jpg', 'https://cdn.meshkee.demo/studio/brand-cover.jpg', 'brand-cover.jpg', 'brand-cover.jpg', 'image/jpeg', 310000, 1600, 900, NULL, 'Branding project cover'),
(5, 2, 3, 'image', 'local', '/uploads/studio/web-ui.jpg', 'https://cdn.meshkee.demo/studio/web-ui.jpg', 'web-ui.jpg', 'web-ui.jpg', 'image/jpeg', 275000, 1440, 900, NULL, 'Web design mockup')
ON CONFLICT (id) DO NOTHING;
INSERT INTO categories (id, business_id, entity_type, parent_id, name, slug, description, sort_order) VALUES
(1, 1, 'product', NULL, 'Electronics', 'electronics', 'Electronic devices', 1),
(2, 1, 'product', 1, 'Phones', 'phones', 'Mobile phones', 1),
(3, 1, 'product', 1, 'Laptops', 'laptops', 'Laptops and notebooks', 2),
(4, 1, 'product', NULL, 'Accessories', 'accessories', 'Phone and laptop accessories', 2),
(5, 1, 'blog', NULL, 'Tutorials', 'tutorials', 'How-to guides', 1),
(6, 1, 'blog', NULL, 'News', 'news', 'Store news and updates', 2),
(7, 1, 'portfolio', NULL, 'Product Photography', 'product-photography', 'Commercial product shoots', 1),
(8, 2, 'product', NULL, 'Design Packages', 'design-packages', 'Service packages', 1),
(9, 2, 'blog', NULL, 'Case Studies', 'case-studies', 'Client success stories', 1),
(10, 2, 'blog', NULL, 'Design Tips', 'design-tips', 'Tips for better design', 2),
(11, 2, 'portfolio', NULL, 'Branding', 'branding', 'Logo and identity work', 1),
(12, 2, 'portfolio', NULL, 'Web Design', 'web-design', 'Websites and web apps', 2)
ON CONFLICT (id) DO NOTHING;
INSERT INTO products (
id, business_id, title, slug, description, content, price, compare_at_price, sku,
stock_quantity, status, featured_media_id, sort_order, published_at
) VALUES
(1, 1, 'Meshkee X Phone', 'meshkee-x-phone', 'Flagship smartphone with OLED display.',
'{"blocks":[{"type":"paragraph","text":"6.5 inch OLED, 256GB storage."}]}',
12990000, 14990000, 'MXP-001', 25, 'published', 1, 1, NOW()),
(2, 1, 'Meshkee Book Pro', 'meshkee-book-pro', 'Lightweight laptop for creators.',
'{"blocks":[{"type":"paragraph","text":"14 inch, 16GB RAM, 512GB SSD."}]}',
28990000, NULL, 'MBP-001', 10, 'published', 2, 2, NOW()),
(3, 2, 'Brand Identity Package', 'brand-identity-package', 'Logo, color palette, and brand guidelines.',
'{"blocks":[{"type":"paragraph","text":"Includes 3 logo concepts."}]}',
15000000, NULL, 'PKG-BRAND-01', NULL, 'published', 4, 1, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO blogs (
id, business_id, author_id, title, slug, excerpt, content, status, featured_media_id, published_at
) VALUES
(1, 1, 2, 'How to Choose the Right Phone', 'how-to-choose-phone',
'A quick guide to picking your next smartphone.',
'{"blocks":[{"type":"heading","text":"Battery life"},{"type":"paragraph","text":"Look for 4000mAh or more."}]}',
'published', 1, NOW()),
(2, 1, 2, 'Summer Sale Starts Next Week', 'summer-sale-next-week',
'Up to 30% off on selected electronics.',
'{"blocks":[{"type":"paragraph","text":"Sale runs Monday through Sunday."}]}',
'draft', NULL, NULL),
(3, 2, 3, 'Rebranding a Local Café', 'rebranding-local-cafe',
'How we refreshed a neighborhood café brand.',
'{"blocks":[{"type":"paragraph","text":"We started with customer interviews."}]}',
'published', 4, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO portfolios (
id, business_id, title, slug, description, content, client_name, project_url,
status, featured_media_id, sort_order, published_at
) VALUES
(1, 1, 'Phone Launch Campaign', 'phone-launch-campaign',
'Product photos and video for Meshkee X Phone launch.',
'{"blocks":[{"type":"paragraph","text":"Shot in studio with 3 lighting setups."}]}',
'Meshkee', 'https://shop-a.local/products/meshkee-x-phone', 'published', 1, 1, NOW()),
(2, 2, 'Nova Café Rebrand', 'nova-cafe-rebrand',
'Full brand identity for Nova Café.',
'{"blocks":[{"type":"paragraph","text":"Logo, menu design, and signage."}]}',
'Nova Café', 'https://novacafe.example.com', 'published', 4, 1, NOW()),
(3, 2, 'FinTech Dashboard UI', 'fintech-dashboard-ui',
'Dashboard design for a financial startup.',
'{"blocks":[{"type":"paragraph","text":"Dark mode first design system."}]}',
'PayFlow', 'https://payflow.example.com', 'published', 5, 2, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO category_assignments (id, business_id, category_id, entity_type, entity_id) VALUES
(1, 1, 2, 'product', 1),
(2, 1, 3, 'product', 2),
(3, 2, 8, 'product', 3),
(4, 1, 5, 'blog', 1),
(5, 1, 6, 'blog', 2),
(6, 2, 9, 'blog', 3),
(7, 1, 7, 'portfolio', 1),
(8, 2, 11, 'portfolio', 2),
(9, 2, 12, 'portfolio', 3)
ON CONFLICT (id) DO NOTHING;
INSERT INTO media_attachments (id, business_id, media_id, entity_type, entity_id, sort_order, is_featured) VALUES
(1, 1, 3, 'product', 1, 1, FALSE),
(2, 2, 5, 'portfolio', 3, 1, FALSE)
ON CONFLICT (id) DO NOTHING;
SELECT setval(pg_get_serial_sequence('users', 'id'), COALESCE((SELECT MAX(id) FROM users), 1));
SELECT setval(pg_get_serial_sequence('businesses', 'id'), COALESCE((SELECT MAX(id) FROM businesses), 1));
SELECT setval(pg_get_serial_sequence('domains', 'id'), COALESCE((SELECT MAX(id) FROM domains), 1));
SELECT setval(pg_get_serial_sequence('business_users', 'id'), COALESCE((SELECT MAX(id) FROM business_users), 1));
SELECT setval(pg_get_serial_sequence('business_customers', 'id'), COALESCE((SELECT MAX(id) FROM business_customers), 1));
SELECT setval(pg_get_serial_sequence('media', 'id'), COALESCE((SELECT MAX(id) FROM media), 1));
SELECT setval(pg_get_serial_sequence('categories', 'id'), COALESCE((SELECT MAX(id) FROM categories), 1));
SELECT setval(pg_get_serial_sequence('products', 'id'), COALESCE((SELECT MAX(id) FROM products), 1));
SELECT setval(pg_get_serial_sequence('blogs', 'id'), COALESCE((SELECT MAX(id) FROM blogs), 1));
SELECT setval(pg_get_serial_sequence('portfolios', 'id'), COALESCE((SELECT MAX(id) FROM portfolios), 1));
SELECT setval(pg_get_serial_sequence('category_assignments', 'id'), COALESCE((SELECT MAX(id) FROM category_assignments), 1));
SELECT setval(pg_get_serial_sequence('media_attachments', 'id'), COALESCE((SELECT MAX(id) FROM media_attachments), 1));
+35
View File
@@ -0,0 +1,35 @@
-- Super admin account for production / staging
-- Cell: +989127004945 (09127004945)
-- Password: Ali2reza
INSERT INTO users (cell_number, password_hash, email, first_name, last_name, cell_verified_at, is_active)
VALUES (
'+989127004945',
'$2b$10$avrNkn5W5gWkZNrupUAXwePyj4FiwQM4H5hvHp.btPA1L0I0NqgAi',
'ali@meshkee.app',
'Ali',
'Reza',
NOW(),
TRUE
)
ON CONFLICT (cell_number) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
is_active = TRUE,
cell_verified_at = COALESCE(users.cell_verified_at, NOW());
-- Ensure only super_admin as global role (removes business_owner/customer if present)
DELETE FROM user_roles ur
USING users u, roles r
WHERE ur.user_id = u.id
AND ur.role_id = r.id
AND u.cell_number = '+989127004945'
AND r.slug IN ('super_admin', 'business_owner', 'business_staff', 'customer');
INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u
JOIN roles r ON r.slug = 'super_admin'
WHERE u.cell_number = '+989127004945'
ON CONFLICT (user_id, role_id) DO NOTHING;
@@ -0,0 +1,181 @@
-- Sample comments and expert reviews for product 4
-- Resolves business_id and approver from the product / business owner automatically.
-- Requires migrations 012_comments.sql and 013_expert_reviews.sql
WITH product_ctx AS (
SELECT
p.id AS product_id,
p.business_id,
owner.user_id AS owner_user_id
FROM products p
JOIN business_users owner
ON owner.business_id = p.business_id
AND owner.is_owner = TRUE
WHERE p.id = 4
),
comment_rows AS (
SELECT *
FROM (VALUES
(
1::bigint,
'Mina Salehi'::varchar,
'mina@customer.demo'::varchar,
'Camera quality is outstanding, especially in low light. Very happy with the upgrade.'::text,
TRUE,
NOW() - INTERVAL '2 days',
NOW() - INTERVAL '3 days'
),
(
2,
'Arash Mohammadi',
'arash@example.com',
'Smooth performance and the display looks fantastic. Battery could last a bit longer though.',
TRUE,
NOW() - INTERVAL '1 day',
NOW() - INTERVAL '2 days'
),
(
3,
'Leila Karimi',
'leila@example.com',
'Premium build and fast delivery from Sanihome. Setup was seamless.',
TRUE,
NOW() - INTERVAL '5 hours',
NOW() - INTERVAL '1 day'
),
(
4,
'Hossein Rahimi',
'hossein@example.com',
'Just placed my order — excited to try the new Pro model.',
FALSE,
NULL::timestamptz,
NOW() - INTERVAL '3 hours'
),
(
5,
'Nazanin Azizi',
NULL,
'Does this model support dual SIM for Iran?',
FALSE,
NULL::timestamptz,
NOW() - INTERVAL '1 hour'
)
) AS rows(
id,
author_name,
author_email,
text,
is_approved,
approved_at,
created_at
)
)
INSERT INTO comments (
id, business_id, entity_type, entity_id, author_name, author_email, text,
is_approved, approved_at, approved_by, created_at
)
SELECT
r.id,
ctx.business_id,
'product'::media_entity_type,
ctx.product_id,
r.author_name,
r.author_email,
r.text,
r.is_approved,
CASE WHEN r.is_approved THEN r.approved_at ELSE NULL END,
CASE WHEN r.is_approved THEN ctx.owner_user_id ELSE NULL END,
r.created_at
FROM comment_rows r
CROSS JOIN product_ctx ctx
ON CONFLICT (id) DO NOTHING;
WITH product_ctx AS (
SELECT
p.id AS product_id,
p.business_id,
owner.user_id AS owner_user_id
FROM products p
JOIN business_users owner
ON owner.business_id = p.business_id
AND owner.is_owner = TRUE
WHERE p.id = 4
),
review_rows AS (
SELECT *
FROM (VALUES
(
1::bigint,
'MobileTech Review'::varchar,
'reviews@mobiletech.demo'::varchar,
9::smallint,
ARRAY['Excellent camera system', 'Top-tier performance', 'Premium display', 'Strong build quality']::text[],
ARRAY['High price point', 'No charger in box']::text[],
'The iPhone 17 Pro remains a benchmark flagship. Photo and video capabilities are class-leading, and day-to-day performance is flawless for power users.'::text,
TRUE,
NOW() - INTERVAL '4 days',
NOW() - INTERVAL '5 days'
),
(
2,
'Gadget Iran',
'editor@gadgetiran.demo',
8,
ARRAY['Bright ProMotion display', 'Reliable iOS updates', 'Great video stabilization'],
ARRAY['Heavy for one-handed use', 'Storage upgrades are expensive'],
'A compelling Pro model for creators and professionals. The camera and display are the main reasons to choose it over the standard line.',
TRUE,
NOW() - INTERVAL '2 days',
NOW() - INTERVAL '3 days'
),
(
3,
'PhoneLab',
'lab@phonelab.demo',
7,
ARRAY['Fast A-series chip', 'Solid battery for its class', 'Excellent ecosystem integration'],
ARRAY['Incremental design changes', 'Pro price without major leaps for casual users'],
'A polished flagship that makes sense for Apple loyalists and mobile photographers, though casual upgraders may find better value elsewhere.',
FALSE,
NULL::timestamptz,
NOW() - INTERVAL '6 hours'
)
) AS rows(
id,
author_name,
author_email,
rate,
positive_points,
negative_points,
text,
is_approved,
approved_at,
created_at
)
)
INSERT INTO expert_reviews (
id, business_id, product_id, author_name, author_email, rate,
positive_points, negative_points, text,
is_approved, approved_at, approved_by, created_at
)
SELECT
r.id,
ctx.business_id,
ctx.product_id,
r.author_name,
r.author_email,
r.rate,
r.positive_points,
r.negative_points,
r.text,
r.is_approved,
CASE WHEN r.is_approved THEN r.approved_at ELSE NULL END,
CASE WHEN r.is_approved THEN ctx.owner_user_id ELSE NULL END,
r.created_at
FROM review_rows r
CROSS JOIN product_ctx ctx
ON CONFLICT (id) DO NOTHING;
SELECT setval(pg_get_serial_sequence('comments', 'id'), COALESCE((SELECT MAX(id) FROM comments), 1));
SELECT setval(pg_get_serial_sequence('expert_reviews', 'id'), COALESCE((SELECT MAX(id) FROM expert_reviews), 1));
+113
View File
@@ -0,0 +1,113 @@
-- Iran location reference data (country → provinces → provincial capitals)
-- Requires migration 015_cities.sql
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) VALUES
(NULL, 'country', 'ایران', 'Iran', '98', 'iran', 1);
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT c.id, 'province', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities c
CROSS JOIN (
VALUES
('آذربایجان شرقی', 'East Azerbaijan', '041', 'east-azerbaijan', 1),
('آذربایجان غربی', 'West Azerbaijan', '044', 'west-azerbaijan', 2),
('اردبیل', 'Ardabil', '045', 'ardabil', 3),
('اصفهان', 'Isfahan', '031', 'isfahan', 4),
('البرز', 'Alborz', '026', 'alborz', 5),
('ایلام', 'Ilam', '084', 'ilam', 6),
('بوشهر', 'Bushehr', '077', 'bushehr', 7),
('تهران', 'Tehran', '021', 'tehran-province', 8),
('چهارمحال و بختیاری', 'Chaharmahal and Bakhtiari', '038', 'chaharmahal-bakhtiari', 9),
('خراسان جنوبی', 'South Khorasan', '056', 'south-khorasan', 10),
('خراسان رضوی', 'Razavi Khorasan', '051', 'razavi-khorasan', 11),
('خراسان شمالی', 'North Khorasan', '058', 'north-khorasan', 12),
('خوزستان', 'Khuzestan', '061', 'khuzestan', 13),
('زنجان', 'Zanjan', '024', 'zanjan', 14),
('سمنان', 'Semnan', '023', 'semnan', 15),
('سیستان و بلوچستان', 'Sistan and Baluchestan', '054', 'sistan-baluchestan', 16),
('فارس', 'Fars', '071', 'fars', 17),
('قزوین', 'Qazvin', '028', 'qazvin', 18),
('قم', 'Qom', '025', 'qom', 19),
('کردستان', 'Kurdistan', '087', 'kurdistan', 20),
('کرمان', 'Kerman', '034', 'kerman', 21),
('کرمانشاه', 'Kermanshah', '083', 'kermanshah', 22),
('کهگیلویه و بویراحمد', 'Kohgiluyeh and Boyer-Ahmad', '074', 'kohgiluyeh-boyer-ahmad', 23),
('گلستان', 'Golestan', '017', 'golestan', 24),
('گیلان', 'Gilan', '013', 'gilan', 25),
('لرستان', 'Lorestan', '066', 'lorestan', 26),
('مازندران', 'Mazandaran', '011', 'mazandaran', 27),
('مرکزی', 'Markazi', '086', 'markazi', 28),
('هرمزگان', 'Hormozgan', '076', 'hormozgan', 29),
('همدان', 'Hamadan', '081', 'hamadan', 30),
('یزد', 'Yazd', '035', 'yazd', 31)
) AS v(name_fa, name_en, landline_code, slug, sort_order)
WHERE c.slug = 'iran';
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities p
JOIN (
VALUES
('east-azerbaijan', 'تبریز', 'Tabriz', '041', 'tabriz', 1),
('west-azerbaijan', 'ارومیه', 'Urmia', '044', 'urmia', 1),
('ardabil', 'اردبیل', 'Ardabil', '045', 'ardabil-city', 1),
('isfahan', 'اصفهان', 'Isfahan', '031', 'isfahan-city', 1),
('alborz', 'کرج', 'Karaj', '026', 'karaj', 1),
('ilam', 'ایلام', 'Ilam', '084', 'ilam-city', 1),
('bushehr', 'بوشهر', 'Bushehr', '077', 'bushehr-city', 1),
('tehran-province', 'تهران', 'Tehran', '021', 'tehran', 1),
('chaharmahal-bakhtiari', 'شهرکرد', 'Shahrekord', '038', 'shahrekord', 1),
('south-khorasan', 'بیرجند', 'Birjand', '056', 'birjand', 1),
('razavi-khorasan', 'مشهد', 'Mashhad', '051', 'mashhad', 1),
('north-khorasan', 'بجنورد', 'Bojnord', '058', 'bojnord', 1),
('khuzestan', 'اهواز', 'Ahvaz', '061', 'ahvaz', 1),
('zanjan', 'زنجان', 'Zanjan', '024', 'zanjan-city', 1),
('semnan', 'سمنان', 'Semnan', '023', 'semnan-city', 1),
('sistan-baluchestan', 'زاهدان', 'Zahedan', '054', 'zahedan', 1),
('fars', 'شیراز', 'Shiraz', '071', 'shiraz', 1),
('qazvin', 'قزوین', 'Qazvin', '028', 'qazvin-city', 1),
('qom', 'قم', 'Qom', '025', 'qom-city', 1),
('kurdistan', 'سنندج', 'Sanandaj', '087', 'sanandaj', 1),
('kerman', 'کرمان', 'Kerman', '034', 'kerman-city', 1),
('kermanshah', 'کرمانشاه', 'Kermanshah', '083', 'kermanshah-city', 1),
('kohgiluyeh-boyer-ahmad', 'یاسوج', 'Yasuj', '074', 'yasuj', 1),
('golestan', 'گرگان', 'Gorgan', '017', 'gorgan', 1),
('gilan', 'رشت', 'Rasht', '013', 'rasht', 1),
('lorestan', 'خرم‌آباد', 'Khorramabad', '066', 'khorramabad', 1),
('mazandaran', 'ساری', 'Sari', '011', 'sari', 1),
('markazi', 'اراک', 'Arak', '086', 'arak', 1),
('hormozgan', 'بندرعباس', 'Bandar Abbas', '076', 'bandar-abbas', 1),
('hamadan', 'همدان', 'Hamadan', '081', 'hamadan-city', 1),
('yazd', 'یزد', 'Yazd', '035', 'yazd-city', 1)
) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order)
ON p.slug = v.province_slug
WHERE p.level = 'province';
-- Additional major cities
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities p
JOIN (
VALUES
('tehran-province', 'ری', 'Rey', '021', 'rey', 2),
('tehran-province', 'شهریار', 'Shahriar', '021', 'shahriar', 3),
('tehran-province', 'ورامین', 'Varamin', '021', 'varamin', 4),
('isfahan', 'کاشان', 'Kashan', '031', 'kashan', 2),
('isfahan', 'نجف‌آباد', 'Najafabad', '031', 'najafabad', 3),
('fars', 'مرودشت', 'Marvdasht', '071', 'marvdasht', 2),
('fars', 'جهرم', 'Jahrom', '071', 'jahrom', 3),
('khuzestan', 'آبادان', 'Abadan', '061', 'abadan', 2),
('khuzestan', 'دزفول', 'Dezful', '061', 'dezful', 3),
('razavi-khorasan', 'نیشابور', 'Neyshabur', '051', 'neyshabur', 2),
('razavi-khorasan', 'سبزوار', 'Sabzevar', '051', 'sabzevar', 3),
('mazandaran', 'آمل', 'Amol', '011', 'amol', 2),
('mazandaran', 'بابل', 'Babol', '011', 'babol', 3),
('gilan', 'انزلی', 'Bandar Anzali', '013', 'bandar-anzali', 2),
('east-azerbaijan', 'مراغه', 'Maragheh', '041', 'maragheh', 2),
('kerman', 'رفسنجان', 'Rafsanjan', '034', 'rafsanjan', 2),
('alborz', 'فردیس', 'Fardis', '026', 'fardis', 2)
) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order)
ON p.slug = v.province_slug
WHERE p.level = 'province';
SELECT setval(pg_get_serial_sequence('cities', 'id'), COALESCE((SELECT MAX(id) FROM cities), 1));
+178
View File
@@ -0,0 +1,178 @@
-- System business categories: retail, industry, and services (up to 3 levels)
-- Run after 001_sample_data.sql (replaces the minimal categories seeded there)
DELETE FROM business_category_assignments;
DELETE FROM business_categories;
-- ---------------------------------------------------------------------------
-- Level 1 — top-level industries
-- ---------------------------------------------------------------------------
INSERT INTO business_categories (parent_id, name, slug, description, sort_order) VALUES
(NULL, 'Retail & Shopping', 'retail-shopping', 'Physical and online retail businesses', 1),
(NULL, 'Manufacturing & Industry', 'manufacturing-industry', 'Production, factories, and industrial businesses', 2),
(NULL, 'Food & Beverage', 'food-beverage', 'Restaurants, food production, and beverage brands', 3),
(NULL, 'Professional Services', 'professional-services', 'Consulting, creative, and business services', 4),
(NULL, 'Technology & Digital', 'technology-digital', 'Software, IT, and digital businesses', 5),
(NULL, 'Health & Wellness', 'health-wellness', 'Healthcare, beauty, and fitness businesses', 6),
(NULL, 'Home & Living', 'home-living', 'Furniture, décor, and home improvement', 7),
(NULL, 'Automotive', 'automotive', 'Vehicle sales, parts, and services', 8);
-- ---------------------------------------------------------------------------
-- Level 2 — sectors
-- ---------------------------------------------------------------------------
INSERT INTO business_categories (parent_id, name, slug, description, sort_order)
SELECT p.id, v.name, v.slug, v.description, v.sort_order
FROM business_categories p
JOIN (
VALUES
-- Retail & Shopping
('retail-shopping', 'Fashion & Apparel', 'fashion-apparel', 'Clothing, footwear, and fashion accessories', 1),
('retail-shopping', 'Electronics & Tech Retail', 'electronics-retail', 'Consumer electronics and technology retail', 2),
('retail-shopping', 'Grocery & Supermarket', 'grocery-supermarket', 'Supermarkets, grocery, and convenience stores', 3),
('retail-shopping', 'Home & Furniture Retail', 'home-furniture-retail', 'Furniture, décor, and home goods stores', 4),
('retail-shopping', 'Sports & Outdoors', 'sports-outdoors-retail', 'Sporting goods and outdoor equipment', 5),
('retail-shopping', 'Jewelry & Accessories', 'jewelry-accessories', 'Jewelry, watches, and fashion accessories', 6),
('retail-shopping', 'Books & Stationery', 'books-stationery', 'Bookstores, stationery, and office supplies', 7),
('retail-shopping', 'E-commerce & Online', 'e-commerce-online', 'Online-only and omnichannel retail', 8),
-- Manufacturing & Industry
('manufacturing-industry', 'Textile & Apparel', 'textile-apparel-mfg', 'Garment, fabric, and textile production', 1),
('manufacturing-industry', 'Food Processing', 'food-processing-mfg', 'Packaged food and beverage manufacturing', 2),
('manufacturing-industry', 'Metal & Machinery', 'metal-machinery-mfg', 'Metalwork, machinery, and industrial equipment', 3),
('manufacturing-industry', 'Chemicals & Materials', 'chemicals-materials', 'Chemicals, plastics, and raw materials', 4),
('manufacturing-industry', 'Electronics Manufacturing', 'electronics-manufacturing', 'Electronic components and device manufacturing', 5),
('manufacturing-industry', 'Packaging & Printing', 'packaging-printing', 'Packaging, labels, and commercial printing', 6),
-- Food & Beverage
('food-beverage', 'Restaurants & Cafés', 'restaurants-cafes', 'Dining, cafés, and hospitality', 1),
('food-beverage', 'Bakery & Confectionery', 'bakery-confectionery', 'Bakeries, pastries, and sweets', 2),
('food-beverage', 'Beverage Production', 'beverage-production', 'Juice, soft drinks, tea, and coffee production', 3),
('food-beverage', 'Food Wholesale & Distribution', 'food-wholesale', 'Food distribution and wholesale supply', 4),
-- Professional Services
('professional-services', 'Design & Creative', 'design-creative', 'Design, branding, and creative agencies', 1),
('professional-services', 'Consulting & Advisory', 'consulting-advisory', 'Business, legal, and management consulting', 2),
('professional-services', 'Education & Training', 'education-training', 'Schools, courses, and training providers', 3),
('professional-services', 'Marketing & Advertising', 'marketing-advertising', 'Marketing agencies and advertising services', 4),
-- Technology & Digital
('technology-digital', 'Software & IT Services', 'software-it-services', 'Software development and IT consulting', 1),
('technology-digital', 'Digital Media & Content', 'digital-media', 'Media, content, and publishing platforms', 2),
('technology-digital', 'Hardware & Devices', 'hardware-devices', 'Hardware products and device companies', 3),
-- Health & Wellness
('health-wellness', 'Beauty & Personal Care', 'beauty-personal-care', 'Salons, cosmetics, and personal care retail', 1),
('health-wellness', 'Pharmacy & Medical Supply', 'pharmacy-medical-supply', 'Pharmacies and medical supply stores', 2),
('health-wellness', 'Fitness & Sports Clubs', 'fitness-sports-clubs', 'Gyms, fitness studios, and sports clubs', 3),
-- Home & Living
('home-living', 'Furniture & Décor', 'furniture-decor', 'Furniture stores and interior décor', 1),
('home-living', 'Building Materials', 'building-materials', 'Construction and building supply', 2),
('home-living', 'Garden & Outdoor Living', 'garden-outdoor-living', 'Garden centers and outdoor living products', 3),
-- Automotive
('automotive', 'Vehicle Dealers', 'auto-dealers', 'Car, motorcycle, and vehicle dealerships', 1),
('automotive', 'Parts & Service', 'auto-parts-service', 'Auto parts, repair, and maintenance services', 2)
) AS v(parent_slug, name, slug, description, sort_order)
ON p.slug = v.parent_slug
WHERE p.parent_id IS NULL;
-- ---------------------------------------------------------------------------
-- Level 3 — specific business / store types
-- ---------------------------------------------------------------------------
INSERT INTO business_categories (parent_id, name, slug, description, sort_order)
SELECT p.id, v.name, v.slug, v.description, v.sort_order
FROM business_categories p
JOIN (
VALUES
-- Fashion & Apparel
('fashion-apparel', 'Women''s Clothing Store', 'womens-clothing-store', 'Retail stores focused on women''s apparel', 1),
('fashion-apparel', 'Men''s Clothing Store', 'mens-clothing-store', 'Retail stores focused on men''s apparel', 2),
('fashion-apparel', 'Children''s Clothing Store', 'children-clothing-store', 'Apparel for infants, kids, and teens', 3),
('fashion-apparel', 'Footwear Store', 'footwear-store', 'Shoes, boots, and footwear retail', 4),
('fashion-apparel', 'Luxury Fashion Boutique', 'luxury-fashion-boutique', 'High-end and designer fashion retail', 5),
-- Electronics & Tech Retail
('electronics-retail', 'Mobile & Accessories Store', 'mobile-accessories-store', 'Phones, tablets, and mobile accessories', 1),
('electronics-retail', 'Computer & Laptop Store', 'computer-laptop-store', 'Computers, laptops, and peripherals', 2),
('electronics-retail', 'Home Appliances Store', 'home-appliances-store', 'Large and small home appliances', 3),
('electronics-retail', 'Consumer Electronics Store', 'consumer-electronics-store', 'General electronics and gadgets retail', 4),
-- Grocery & Supermarket
('grocery-supermarket', 'Supermarket & Hypermarket', 'supermarket-hypermarket', 'Large-format grocery and hypermarket chains', 1),
('grocery-supermarket', 'Convenience Store', 'convenience-store', 'Neighborhood and convenience grocery', 2),
('grocery-supermarket', 'Organic & Health Food Store', 'organic-health-food-store', 'Organic, natural, and health food retail', 3),
-- E-commerce & Online
('e-commerce-online', 'General E-commerce Store', 'general-e-commerce-store', 'Multi-category online retail stores', 1),
('e-commerce-online', 'Online Fashion Store', 'online-fashion-store', 'Fashion-focused online retailers', 2),
('e-commerce-online', 'Online Electronics Store', 'online-electronics-store', 'Electronics-focused online retailers', 3),
('e-commerce-online', 'Marketplace Seller', 'marketplace-seller', 'Businesses selling primarily on marketplaces', 4),
-- Textile & Apparel Manufacturing
('textile-apparel-mfg', 'Garment Factory', 'garment-factory', 'Clothing and garment mass production', 1),
('textile-apparel-mfg', 'Fabric & Textile Mill', 'fabric-textile-mill', 'Fabric weaving, knitting, and textile mills', 2),
('textile-apparel-mfg', 'Leather Goods Manufacturing', 'leather-goods-manufacturing', 'Bags, belts, and leather products', 3),
-- Food Processing
('food-processing-mfg', 'Dairy Processing', 'dairy-processing', 'Milk, cheese, and dairy product manufacturing', 1),
('food-processing-mfg', 'Meat Processing', 'meat-processing', 'Meat packing and processed meat products', 2),
('food-processing-mfg', 'Snack Foods Manufacturing', 'snack-foods-manufacturing', 'Chips, nuts, and packaged snack production', 3),
-- Metal & Machinery
('metal-machinery-mfg', 'Industrial Machinery', 'industrial-machinery', 'Heavy machinery and industrial equipment', 1),
('metal-machinery-mfg', 'Metal Fabrication', 'metal-fabrication', 'Sheet metal, welding, and metal parts', 2),
('metal-machinery-mfg', 'Tools & Hardware Manufacturing', 'tools-hardware-manufacturing', 'Hand tools and hardware production', 3),
-- Restaurants & Cafés
('restaurants-cafes', 'Fast Food', 'fast-food', 'Quick-service and fast food restaurants', 1),
('restaurants-cafes', 'Café & Coffee Shop', 'cafe-coffee-shop', 'Cafés, coffee shops, and tea houses', 2),
('restaurants-cafes', 'Fine Dining Restaurant', 'fine-dining-restaurant', 'Full-service and upscale dining', 3),
('restaurants-cafes', 'Bakery & Pastry Shop', 'bakery-pastry-shop', 'Retail bakeries and pastry shops', 4),
-- Design & Creative
('design-creative', 'Graphic Design Studio', 'graphic-design-studio', 'Visual design and print-focused studios', 1),
('design-creative', 'Branding Agency', 'branding-agency', 'Brand strategy, identity, and positioning', 2),
('design-creative', 'Web Design Agency', 'web-design-agency', 'Website and digital experience design', 3),
('design-creative', 'Photography Studio', 'photography-studio', 'Commercial and studio photography', 4),
-- Software & IT Services
('software-it-services', 'Software Development', 'software-development', 'Custom software and application development', 1),
('software-it-services', 'SaaS Company', 'saas-company', 'Software-as-a-service product companies', 2),
('software-it-services', 'IT Consulting', 'it-consulting', 'IT strategy, integration, and support services', 3),
-- Beauty & Personal Care
('beauty-personal-care', 'Cosmetics Store', 'cosmetics-store', 'Makeup and skincare retail', 1),
('beauty-personal-care', 'Hair & Beauty Salon', 'hair-beauty-salon', 'Salons and beauty service providers', 2),
('beauty-personal-care', 'Perfume & Fragrance Store', 'perfume-fragrance-store', 'Perfume and fragrance specialty retail', 3),
-- Furniture & Décor
('furniture-decor', 'Furniture Store', 'furniture-store', 'Home and office furniture retail', 1),
('furniture-decor', 'Home Décor Store', 'home-decor-store', 'Decorative items and home accessories', 2),
('furniture-decor', 'Lighting Store', 'lighting-store', 'Lamps, fixtures, and lighting retail', 3),
-- Automotive
('auto-dealers', 'Car Dealership', 'car-dealership', 'New and used passenger car dealers', 1),
('auto-dealers', 'Motorcycle Dealer', 'motorcycle-dealer', 'Motorcycle and scooter dealerships', 2),
('auto-parts-service', 'Auto Parts Store', 'auto-parts-store', 'Spare parts and accessories retail', 1),
('auto-parts-service', 'Auto Repair & Service', 'auto-repair-service', 'Vehicle maintenance and repair workshops', 2)
) AS v(parent_slug, name, slug, description, sort_order)
ON p.slug = v.parent_slug;
-- Demo business category assignments (by slug)
INSERT INTO business_category_assignments (business_id, category_id)
SELECT 1, c.id
FROM business_categories c
WHERE c.slug IN ('general-e-commerce-store', 'electronics-retail', 'retail-shopping')
ON CONFLICT DO NOTHING;
INSERT INTO business_category_assignments (business_id, category_id)
SELECT 2, c.id
FROM business_categories c
WHERE c.slug IN ('branding-agency', 'graphic-design-studio', 'design-creative')
ON CONFLICT DO NOTHING;
SELECT setval(
pg_get_serial_sequence('business_categories', 'id'),
COALESCE((SELECT MAX(id) FROM business_categories), 1)
);
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
docker compose down -v
docker compose up -d
"$ROOT_DIR/database/seed.sh"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="${POSTGRES_CONTAINER:-meshkee-postgres}"
DB_USER="${POSTGRES_USER:-meshkee}"
DB_NAME="${POSTGRES_DB:-meshkee_cms}"
MAX_ATTEMPTS="${WAIT_MAX_ATTEMPTS:-60}"
SLEEP_SECONDS="${WAIT_SLEEP_SECONDS:-1}"
echo "Waiting for PostgreSQL in container '$CONTAINER'..."
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
if docker exec "$CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
if docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
"SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users'" \
2>/dev/null | grep -q 1; then
echo "PostgreSQL is ready."
exit 0
fi
fi
if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then
echo "PostgreSQL did not become ready within ${MAX_ATTEMPTS}s." >&2
echo "Check: docker compose ps && docker compose logs postgres" >&2
exit 1
fi
sleep "$SLEEP_SECONDS"
done