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
+34
View File
@@ -0,0 +1,34 @@
---
description: Business-scoped RBAC and permission patterns
globs: src/**/*.controller.ts,src/**/*.service.ts
alwaysApply: false
---
# Business RBAC
## Permission slugs
Format: `resource.action` — e.g. `products.read`, `categories.update`, `business.team.invite`
Content resources: `products.*`, `categories.*`, `media.*`, `business.team.*`
## Who gets access
- `super_admin` → all permissions (bypasses business guard)
- Business owner (`isOwner=true`) → `business_owner` role permissions
- Team member → permissions from `business_users.role_id` (admin/editor/viewer)
## Adding a business-scoped endpoint
1. Route: `businesses/:businessId/<resource>`
2. `@UseGuards(JwtAuthGuard, BusinessPermissionGuard)`
3. `@RequireBusinessPermission('resource.action')` on handler
4. `assertPermission()` again inside the service
## Platform-only endpoints
Super-admin routes (`/users`, `/businesses`, `/domains`) check `permissions.isSuperAdmin()` in the service — no `BusinessPermissionGuard`.
## New permissions
Add `INSERT INTO permissions` + `role_permissions` in a SQL migration, then assign to relevant team roles.
+33
View File
@@ -0,0 +1,33 @@
---
description: SQL migration and Prisma schema workflow
globs: database/**/*,prisma/**/*
alwaysApply: false
---
# Database Workflow
## Migrations
- Raw SQL in `database/migrations/` — numbered files (e.g. `012_feature.sql`)
- Apply: `./database/migrate.sh` or `docker exec` into `meshkee-postgres`
- Docker auto-runs migrations only on **first** Postgres volume init
## After schema change
```bash
npm run prisma:pull
npm run prisma:generate
```
Never edit `prisma/schema.prisma` without a corresponding SQL migration (except post-pull formatting).
## Conventions in SQL
- `set_updated_at()` triggers on mutable tables
- `BIGINT GENERATED BY DEFAULT AS IDENTITY` for PKs
- Foreign keys with explicit `ON DELETE` (Cascade for owned data, Restrict/SetNull where appropriate)
- Seed permissions in the same migration when adding new resources
## Prisma relations
Keep relation names aligned with existing schema style. `businessId` maps to `business_id`, enums use `@@map` for snake_case DB names.
+35
View File
@@ -0,0 +1,35 @@
---
description: Meshkee CMS API project context and architecture essentials
alwaysApply: true
---
# Meshkee CMS API
Read `docs/PROJECT_CONTEXT.md` for full reference before large changes.
## Stack
NestJS 11 + TypeScript + Prisma 6 + PostgreSQL 16 + Redis + S3 (Parmin).
API prefix: `/api/v1`. Package name: `meshkee-cms-api`.
## Architecture
- Multi-tenant: `Business` is the tenant root; routes are `businesses/:businessId/...`
- **Content Category** (`categories`) ≠ **Business Category** (`business_categories`) — do not confuse them
- **Location Cities** (`cities`) — system reference tree (country → province → city) for address forms; not business-scoped. Distinct from **Addresses** (`addresses`) which store user/business street addresses
- Blogs/portfolios exist in DB + permissions but have no Prisma models or API modules yet
## Schema changes
1. Add SQL file in `database/migrations/`
2. Apply via `./database/migrate.sh`
3. Run `npm run prisma:pull` then `npm run prisma:generate`
Do **not** use Prisma Migrate. SQL migrations are authoritative.
## Scope discipline
- Minimize diff scope; match existing module patterns
- Reuse existing services/guards instead of reimplementing
- No commits unless explicitly requested
+46
View File
@@ -0,0 +1,46 @@
---
description: NestJS module, controller, service, and DTO conventions
globs: src/**/*.ts
alwaysApply: false
---
# NestJS Module Pattern
Each feature: `*.module.ts` → `*.controller.ts` → `*.service.ts` → `dto/`
## Controllers
```typescript
@Controller('businesses/:businessId/products')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class ProductsController {
@Get()
@RequireBusinessPermission('products.read')
list(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.list(businessId, user);
}
}
```
## Services
- Convert route IDs with `BigInt(idRaw)`
- Inject `PrismaService` + `PermissionsService`
- Add private `assertPermission(businessId, userId, 'slug')` (defense in depth)
- Use `$transaction` for multi-step writes
- Private `serialize()` methods; return IDs as strings
## DTOs
- `class-validator` on all request bodies and query params
- `@Type(() => Number)` for query coercion
- Slug: `^[a-z0-9]+(?:-[a-z0-9]+)*$`
- Cell: E.164 `^\+[1-9]\d{6,14}$`
## Nested replace resources
For sub-resources like variations or technical forms: `GET` returns state, `PUT` validates → delete-all → recreate in transaction. See `CategoryVariationsService`.
## Errors
Use Nest exceptions: `NotFoundException`, `ForbiddenException`, `BadRequestException`, `ConflictException`.
+44
View File
@@ -0,0 +1,44 @@
# PostgreSQL (DataGrip + API)
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=meshkee
POSTGRES_PASSWORD=meshkee_secret
POSTGRES_DB=meshkee_cms
DATABASE_URL=postgresql://meshkee:meshkee_secret@localhost:5432/meshkee_cms
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
# API
PORT=3000
# JWT
JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars-long
JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars-long
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# SMS (set to true when SMS provider API is ready)
SMS_ENABLED=false
# Object storage (Parmin / S3-compatible)
STORAGE_DISK=s3
S3_ENDPOINT=https://sas.amin.parminstorage.ir
S3_BUCKET=meshkee-storage
S3_PUBLIC_URL=https://meshkee-storage.sas.amin.parminstorage.ir
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# AI product generation (use Groq free tier or OpenAI)
AI_PROVIDER=groq
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
MEDIA_MAX_FILE_SIZE_MB=10
+5
View File
@@ -0,0 +1,5 @@
.env
node_modules/
dist/
.DS_Store
*.log
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MeshkeeApp Backend.iml" filepath="$PROJECT_DIR$/.idea/MeshkeeApp Backend.iml" />
</modules>
</component>
</project>
+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
+12
View File
@@ -0,0 +1,12 @@
# Production extras (optional).
# Base docker-compose.yml already binds Postgres/Redis to 127.0.0.1.
# Do not redeclare the same host ports here — Compose merges by appending
# and that causes "address already in use".
#
# Usage (same as base):
# docker compose up -d
#
# Or explicitly:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services: {}
+38
View File
@@ -0,0 +1,38 @@
services:
postgres:
image: postgres:16-alpine
container_name: meshkee-postgres
restart: unless-stopped
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-meshkee}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-meshkee_secret}
POSTGRES_DB: ${POSTGRES_DB:-meshkee_cms}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meshkee} -d ${POSTGRES_DB:-meshkee_cms}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: meshkee-redis
restart: unless-stopped
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
+178
View File
@@ -0,0 +1,178 @@
# Deploy Meshkee CMS API (Debian VM)
Stack: Docker (Postgres + Redis) → Node build on server → PM2 → Nginx + Let's Encrypt.
App path on server: `/opt/meshkee/app`
API domain: `api.meshkee.com``https://api.meshkee.com/api/v1`
> **Note:** Until the Git remote is accessible from the VM (deploy key / credentials), updates can be synced with `rsync` from your laptop. Pin `sharp@0.33.5` — this VM CPU lacks x64-v2 required by sharp 0.35+.
## Prerequisites
- Debian VM with SSH access
- Domain `A` record pointing at the VM (for HTTPS)
- Git remote with this codebase (private repo → deploy key)
- Production secrets (JWT, Postgres password, S3 keys)
## 1. Server packages
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg git nginx ufw
# Docker
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
# log out/in (or newgrp docker) so docker works without sudo
# Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm install -g pm2
# Certbot (after Nginx is installed)
sudo apt install -y certbot python3-certbot-nginx
```
Firewall:
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
```
## 2. Clone the app
```bash
sudo mkdir -p /opt/meshkee
sudo chown "$USER:$USER" /opt/meshkee
cd /opt/meshkee
git clone <YOUR_GIT_REMOTE_URL> app
cd app
```
Private repo: create an SSH deploy key on the VM (`ssh-keygen -t ed25519 -C "meshkee-deploy"`), add the public key as a read-only deploy key on GitHub/GitLab, clone via SSH URL.
## 3. Production env
```bash
cp .env.example .env
nano .env # set strong secrets — never commit this file
```
Required production values:
- Strong `POSTGRES_PASSWORD` and matching `DATABASE_URL`
- Long random `JWT_ACCESS_SECRET` / `JWT_REFRESH_SECRET`
- Real `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY`
- `PORT=3000`
- `SMS_ENABLED` as needed
## 4. Database + Redis
```bash
cd /opt/meshkee/app
docker compose up -d
docker compose ps
```
First Postgres volume init runs SQL under `database/migrations/` automatically.
Later schema updates:
```bash
./database/migrate.sh
```
## 5. Build and run (on the server)
```bash
cd /opt/meshkee/app
npm ci
npm run prisma:generate
npm run build
```
Production seed (super admin only — skip sample data):
```bash
./database/seed.sh database/seeds/002_super_admin_user.sql
# optional reference data:
# ./database/seed.sh database/seeds/004_iran_cities.sql
# ./database/seed.sh database/seeds/005_business_categories.sql
```
Start with PM2:
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup # run the command it prints (usually with sudo)
```
Health check locally on the VM:
```bash
curl -s http://127.0.0.1:3000/api/v1/ | head
# or hit a known public route such as tenant resolve
```
## 6. Nginx + HTTPS
Create `/etc/nginx/sites-available/meshkee-api`:
```nginx
server {
listen 80;
server_name api.example.com; # replace with your domain
client_max_body_size 15M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable and get a certificate:
```bash
sudo ln -sf /etc/nginx/sites-available/meshkee-api /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d api.example.com
```
API base URL: `https://api.example.com/api/v1`
## Ongoing updates
```bash
cd /opt/meshkee/app
git pull
./database/migrate.sh # if there are new SQL migrations
npm ci
npm run prisma:generate
npm run build
pm2 restart meshkee-api
```
## Useful commands
```bash
pm2 status
pm2 logs meshkee-api
docker compose logs -f postgres
```
+601
View File
@@ -0,0 +1,601 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: July 2026
## What This Project Is
**Meshkee CMS API** (`meshkee-cms-api`) is a multi-tenant backend for Meshkee business websites. Each business gets its own domain, content (products, categories, media), team members, and customer registrations. Platform super admins manage businesses and domains; business owners and staff manage per-business content through a permission-based dashboard.
**API base URL:** `/api/v1`
---
## Tech Stack
| Layer | Technology |
|-------|------------|
| Runtime | Node.js, TypeScript (ES2021, strict) |
| Framework | NestJS 11 |
| ORM | Prisma 6 (`prisma db pull` — schema is introspected, not migrated via Prisma) |
| Database | PostgreSQL 16 |
| Cache | Redis 7 (OTP storage) |
| Auth | JWT (access + refresh), Passport, bcrypt |
| Validation | class-validator + class-transformer |
| File storage | S3-compatible (Parmin), Sharp for image processing |
---
## Architecture
### Multi-tenancy model
```
Platform (super admin)
└── Business (tenant root)
├── Domains (host → business resolution)
├── Team members (BusinessUser + Role)
├── Customers (BusinessCustomer)
├── Categories (per entityType: product | blog | portfolio)
├── Products
│ └── Store items (product variants — price & stock per variation combo)
├── Media library
└── Settings (JSON)
```
- **Tenant resolution:** `GET /tenants/:host` resolves a domain to a business (public, no auth).
- **Business-scoped APIs:** Most CMS routes use `businesses/:businessId/...` with JWT + business permission checks.
- **Platform APIs:** User/business/domain management requires `super_admin` (checked in services).
### Two "category" concepts
| Concept | Table | Purpose |
|---------|-------|---------|
| **Content Category** | `categories` | Per-business taxonomy for products, blogs, portfolios |
| **Business Category** | `business_categories` | Platform-wide taxonomy classifying businesses (Retail, Creative, etc.) |
Do not confuse them when reading or writing code.
---
## Directory Structure
```
src/
├── main.ts # Bootstrap, global prefix, pipes, interceptors
├── app.module.ts # Root module wiring
├── auth/ # JWT, OTP/SMS, permissions, guards, decorators
├── users/ # Super-admin user management
├── roles/ # Global + team role listing
├── business-admin/ # Super-admin business CRUD + domains
├── business-team/ # Per-business team invite/manage
├── tenant/ # Domain → business resolution (public)
├── domain-admin/ # Super-admin domain management
├── categories/ # Content categories, variations, technical forms
├── products/ # Products, variants, technical info
├── cart/ # Customer shopping cart
├── orders/ # Customer checkout + admin order management
├── media/ # Upload/list/update/delete media
├── storage/ # S3 driver abstraction
├── prisma/ # PrismaModule + PrismaService
├── redis/ # Redis client + OTP helpers
└── common/ # Shared interceptors (BigInt serialization)
prisma/schema.prisma # Introspected from PostgreSQL (source of truth after migrations)
database/
├── migrations/ # Raw SQL migrations (numbered)
├── seeds/ # Dev sample data + production super-admin
├── migrate.sh, seed.sh, setup.sh
postman/ # API collection for manual testing
```
### NestJS module pattern
Each feature follows: `*.module.ts``*.controller.ts``*.service.ts``dto/`
---
## Getting Started
```bash
cp .env.example .env
docker compose up -d # Postgres (auto-runs migrations on first init) + Redis
./database/seed.sh # Sample dev data
npm install
npm run prisma:generate
npm run start:dev # http://localhost:3000/api/v1
```
**Dev credentials** (after `001_sample_data.sql` seed): any seeded user, password `password`.
Example super admin: `+989121111111` / `password`
---
## Database & Migrations
### Workflow
1. Write a new SQL file in `database/migrations/` (e.g. `012_feature.sql`).
2. Apply via `./database/migrate.sh` or `docker exec` into Postgres.
3. Run `npm run prisma:pull` to sync `prisma/schema.prisma`.
4. Run `npm run prisma:generate` to regenerate the client.
**Important:** This project does **not** use Prisma Migrate. SQL migrations are the authoritative schema history. Prisma schema is maintained separately via introspection.
### Migration files
| File | Purpose |
|------|---------|
| `001_initial_schema.sql` | Users, businesses, domains |
| `002_phone_permissions_content_media.sql` | RBAC, media, products, blogs, portfolios |
| `003_categories.sql` | Content categories + assignments |
| `004_user_types_and_business_members.sql` | Super admin, business owner, customer roles |
| `005_business_team_roles.sql` | Team roles (admin, editor, viewer) |
| `006_business_categories.sql` | Platform business category taxonomy |
| `006_user_profile.sql` | User profile JSON |
| `007_business_i18n_fields.sql` | Business Persian name |
| `007_domain_expiry_active.sql` | Domain expiry/active flags |
| `008_remove_name_en.sql` | Schema cleanup |
| `009_categories_name_fa.sql` | Category `name_fa` |
| `010_category_variations.sql` | Category variations, product variants (store items) |
| `011_category_technical_forms.sql` | Technical forms + product values |
| `012_comments.sql` | Product comments |
| `013_expert_reviews.sql` | Expert reviews |
| `014_addresses.sql` | User/business street addresses |
| `015_business_profile.sql` | Business profile fields |
| `015_cities.sql` | Location cities (country → province → city) |
| `016_product_variation_values.sql` | Product-level variation option selections (input for store items) |
| `017_product_variant_festival.sql` | Festival flag on store items |
| `018_product_variant_reward_points.sql` | Reward points on store items |
| `019_cart_and_orders.sql` | Shopping cart, orders, order items + order permissions |
| `020_store_items_and_variants.sql` | `store_items` + `store_item_variants` (replaces `product_variants`) |
| `030_website_homepage.sql` | Website category/brand groups, sliders, brand `sort_order` |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
---
## Domain Model
### Enums
| Enum | Values |
|------|--------|
| `MediaEntityType` | `product`, `blog`, `portfolio` |
| `ContentStatus` | `draft`, `published`, `archived` |
| `VariationType` | `color`, `size`, `custom` |
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
| `OrderSource` | `website`, `admin` |
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
| `MediaType` | `image`, `video` |
### Core relationships
```
Business 1──* Domain
Business 1──* Category (entityType: product|blog|portfolio)
Business 1──* Product
Business 1──* Media
Category 1──* CategoryVariation 1──* CategoryVariationOption
Category 1──0..1 CategoryTechnicalForm 1──* CategoryTechnicalFormField
CategoryTechnicalFormField 1──* CategoryTechnicalFormFieldOption
Product *──0..1 Category (via CategoryAssignment)
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
Product 1──0..1 StoreItem (one shop listing per product)
StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo)
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
Business 1──* Cart (per customer) 1──* CartItem → ProductVariant
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
User 1──* Cart, Order (as customer)
Media 1──* MediaAttachment (polymorphic: entityType + entityId)
```
### Schema gap: blogs & portfolios
SQL migrations create `blogs` and `portfolios` tables and seed data populates them. Permissions exist (`blogs.*`, `portfolios.*`). However:
- No Prisma models for Blog/Portfolio
- No NestJS modules or API endpoints
Categories and media attachments already support `blog` and `portfolio` entity types — infrastructure is ready, API is not.
---
## API Reference
All routes are prefixed with `/api/v1`.
### Public
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/register` | Customer registration by domain |
| POST | `/auth/login` | Cell + password |
| POST | `/auth/refresh` | Refresh token |
| POST | `/auth/send-otp` | Send OTP (Redis-backed) |
| POST | `/auth/verify-otp` | Verify OTP |
| GET | `/tenants/:host` | Resolve business from domain |
| GET | `/tenants/:host/store-specials` | Active store specials |
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
### Authenticated (JWT)
| Method | Path | Access |
|--------|------|--------|
| GET | `/auth/me` | Any user |
| PATCH | `/auth/profile` | Any user |
| POST | `/auth/change-password` | Any user |
| GET | `/roles?scope=global\|team` | Super admin / team.read |
| GET | `/business-categories` | Super admin or `business_categories.read` |
### Super admin only (service-level check)
| Module | Base path |
|--------|-----------|
| Users | `/users` |
| Businesses | `/businesses` |
| Domains | `/domains` |
### Business-scoped (JWT + `BusinessPermissionGuard`)
Pattern: `/businesses/:businessId/<resource>`
| Module | Base path | Key permissions |
|--------|-----------|-----------------|
| Team | `/team` | `business.team.*` |
| Media | `/media` | `media.*` |
| Categories | `/categories` | `categories.*` |
| Products | `/products` | `products.*` |
| Website | `/website/category-groups`, `/website/brand-groups`, `/website/sliders` | `website.*` |
#### Categories — notable sub-routes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/categories/color-presets` | Predefined color palette |
| GET/PUT | `/categories/:id/variations` | Category variation options |
| GET/PUT | `/categories/:id/technical-form` | Technical form definition |
#### Products — notable sub-routes
| Method | Path | Description |
|--------|------|-------------|
| GET/PUT | `/products/:id/variations` | Which category variation options apply to this product |
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
| GET/PUT | `/products/:id/technical-info` | Product technical data |
#### Cart (customer — JWT, must be business customer)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/cart` | Get current user's cart |
| POST | `/cart/items` | Add store item variant to cart (`storeItemVariantId`, `quantity`) |
| PATCH | `/cart/items/:itemId` | Update cart item quantity |
| DELETE | `/cart/items/:itemId` | Remove cart item |
| DELETE | `/cart` | Clear cart |
| POST | `/cart/checkout` | Place order from cart (requires `addressId` or `shippingAddress`) |
#### Orders
| Method | Path | Access | Description |
|--------|------|--------|-------------|
| GET | `/orders` | Customer (own) or `orders.read` (all) | List orders |
| GET | `/orders/:orderId` | Customer (own) or `orders.read` | Order detail |
| POST | `/orders` | `orders.create` | Admin: create order for a customer |
| PATCH | `/orders/:orderId` | `orders.update` | Admin: update status / admin notes |
---
## Auth & RBAC
### Global user roles
| Role slug | Dashboard | Notes |
|-----------|-----------|-------|
| `super_admin` | `super_admin` | Full platform access |
| `business_owner` | `business` | Assigned to business owners |
| `business_staff` | `business` | Legacy; team uses per-business roles |
| `customer` | `customer` | Website registrants |
### Per-business team roles
Assigned via `business_users.role_id`:
| Role | Slug | Typical access |
|------|------|----------------|
| Owner | `isOwner=true` | All `business_owner` permissions |
| Admin | `admin` | Full content + team read |
| Editor | `editor` | Create/edit/publish content |
| Viewer | `viewer` | Read-only |
### Permission groups (seeded)
`business.*`, `domains.*`, `products.*`, `blogs.*`, `portfolios.*`, `media.*`, `categories.*`, `users.*`, `roles.manage`, `business.team.*`, `business_categories.*`
Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` for content).
### Guards & decorators
```typescript
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
@RequireBusinessPermission('products.read')
```
- `JwtAuthGuard` — validates Bearer JWT (`type: 'access'`)
- `BusinessPermissionGuard` — checks permission for `businessId` route param
- `PermissionsService``isSuperAdmin()`, `hasBusinessPermission()`
- Super admins bypass business permission checks
- `@CurrentUser()` injects `AuthUser` into handlers
### Auth flow notes
- Registration resolves tenant by `domain` → creates/links user → assigns `customer` role
- OTP stored in Redis (`otp:{cellNumber}`), 5-min TTL; disabled when `SMS_ENABLED=false`
- JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type`
---
## Key Features
### Products
- CRUD with slug, status, featured media, gallery attachments
- One category per product (via `CategoryAssignment`)
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
- i18n: Persian name in `content.nameFa`, summary in `description`
### Category variations (product categories)
- Types: `color` (preset palette), `size`, `custom`
- One color + one size per category (DB partial unique indexes)
- `PUT /categories/:id/variations` replaces all variations (delete-all + recreate)
- Color presets: `GET /categories/color-presets` (15 named colors with hex)
### Store (shop listings)
The **store** turns CMS products into purchasable items using a two-level model:
```
Product variations → which options this product offers (product_variation_values)
Store item → one shop listing per product (store_items)
Store item variants → purchasable SKUs with price/stock per combination (store_item_variants)
```
| Layer | Table | API | Purpose |
|-------|-------|-----|---------|
| Product variations | `product_variation_values` | `PUT /products/:id/variations` | Which category variation options apply to this product |
| Store item | `store_items` | `GET /store-items/by-product/:productId` | One listing per product in the shop |
| Store item variants | `store_item_variants` + `store_item_variant_selections` | `POST /store-items`, `PUT /store-items/sync` | Purchasable combinations with price & stock |
**Store item variant fields** (`store_item_variants`):
| Field | DB column | Notes |
|-------|-----------|-------|
| `sku` | `sku` | Optional merchant SKU |
| `price` | `price` | Selling price (`NUMERIC(12,2)`) |
| `compareAtPrice` | `compare_at_price` | Optional strike-through / was-price |
| `stockQuantity` | `stock_quantity` | Integer ≥ 0; `null` = untracked |
| `isActive` | `is_active` | Whether variant is available for purchase |
| `selections` | `store_item_variant_selections` | Exactly one `CategoryVariationOption` per category variation |
**Rules:**
- One `store_items` row per product per business.
- Create variants under the store item after setting product variation values.
- Each variant picks one option per variation; the combination must be unique per store item.
- Cart and orders reference `storeItemVariantId` (not the product directly).
**Example — batch create variants for a product:**
```json
POST /businesses/:businessId/store-items
{
"productId": "1",
"items": [
{
"selections": [
{ "variationId": "1", "optionId": "1" },
{ "variationId": "2", "optionId": "5" }
],
"price": 99.99,
"stockQuantity": 10
}
]
}
```
**Example — update a variant:**
```json
PATCH /businesses/:businessId/store-items/variants/:variantId
{
"price": 89.99,
"stockQuantity": 25
}
```
### Technical forms (product categories)
- One form per product category
- Field types: `text`, `textarea`, `select`, `multi_select`
- `PUT /categories/:id/technical-form` — define/replace form fields
- `PUT /products/:id/technical-info` — fill product values (validated against category form)
- Product must have a category assignment
**Example — define form:**
```json
PUT /businesses/:businessId/categories/:categoryId/technical-form
{
"fields": [
{ "label": "Weight", "type": "text", "isRequired": true },
{ "label": "Material", "type": "select", "options": ["Cotton", "Polyester"] },
{ "label": "Features", "type": "multi_select", "options": ["Waterproof", "Breathable"] }
]
}
```
**Example — fill product data:**
```json
PUT /businesses/:businessId/products/:productId/technical-info
{
"values": [
{ "fieldKey": "weight", "value": "500g" },
{ "fieldKey": "material", "value": "cotton" },
{ "fieldKey": "features", "value": ["waterproof", "breathable"] }
]
}
```
Field keys are auto-slugified from labels (e.g. `"Weight"``"weight"`).
### Media
- Multipart upload to S3 via Sharp processing
- Polymorphic attachments to products (and future blog/portfolio entities)
- Featured image on products via `featuredMediaId`
---
## Coding Conventions
### DTOs
- `class-validator` decorators on all request bodies and query params
- `@Type(() => Number)` for query param coercion
- Slug format: `^[a-z0-9]+(?:-[a-z0-9]+)*$`
- Cell numbers: E.164 `^\+[1-9]\d{6,14}$`
### Services
- Inject `PrismaService`, `PermissionsService`
- Convert route param IDs with `BigInt(idRaw)`
- Private `assertPermission()` / `assertSuperAdmin()` helpers
- Use `$transaction` for multi-step writes
- **Replace semantics** for nested resources (variations, technical forms) — delete-all then recreate
### Error handling
Use Nest exceptions: `NotFoundException`, `ForbiddenException`, `BadRequestException`, `ConflictException`, `UnauthorizedException`.
### Serialization
- Global `BigIntSerializerInterceptor` converts BigInt → Number in JSON responses
- Services use private `serialize()` methods for consistent output shapes
- IDs returned as strings in API responses
### Global validation pipe (`main.ts`)
```typescript
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
```
---
## Environment Variables
See `.env.example` for the full list. Key groups:
| Group | Variables |
|-------|-----------|
| Database | `DATABASE_URL`, `POSTGRES_*` |
| Redis | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT` |
| API | `PORT` |
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
| SMS | `SMS_ENABLED` |
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
| Media | `MEDIA_MAX_FILE_SIZE_MB` |
---
## Implemented vs Planned
### Implemented
- Multi-tenant auth (register, login, OTP, profile)
- Super admin: users, businesses, domains, system business categories
- Business team management
- Media upload (S3 + Sharp)
- Categories (all entity types in DB; API supports `entityType` filter)
- Products CRUD
- Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU)
- Shopping cart + checkout + orders (customer + admin)
- Product technical info
- Category variations & technical forms
- Tenant resolution by domain
- RBAC with granular permissions
### Planned / partial
| Feature | DB | Permissions | API | Prisma |
|---------|----|-------------|-----|--------|
| Blogs | Yes | Yes | No | No model |
| Portfolios | Yes | Yes | No | No model |
| Customer dashboard | Partial | No | Register only | Yes |
| SMS provider | — | — | Stub | — |
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
| Customer favorites | — | `favorites.*` seeded | No | No |
---
## Testing
**Postman collection:** `postman/Meshkee-CMS-Auth.postman_collection.json`
Variables: `baseUrl`, `accessToken`, `refreshToken`, `domain`, `businessId`, `categoryId`, `productId`, `variantId`, `variationId`, `optionId`, `cartItemId`, `orderId`
**Store workflow in Postman:** `Business Categories` → set variations → `Business Products` → create product → set product variations → create store item (variant) with price & stock → `Website - Cart` (login as customer) → add to cart → checkout → `Business Orders` (login as owner) to manage.
---
## Common Tasks for Contributors
### Add a new API feature
1. Write SQL migration in `database/migrations/`
2. Apply migration, then `npm run prisma:pull && npm run prisma:generate`
3. Create module: `src/<feature>/` with controller, service, DTOs
4. Register in `app.module.ts`
5. Add permissions to migration if business-scoped
6. Update this document
### Add a business-scoped endpoint
1. Controller: `@Controller('businesses/:businessId/...')`
2. Guards: `@UseGuards(JwtAuthGuard, BusinessPermissionGuard)`
3. Permission: `@RequireBusinessPermission('resource.action')`
4. Service: `assertPermission(businessId, actor.id, 'resource.action')` (defense in depth)
### Add a nested replace resource (like variations)
Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormService`:
- `GET` returns current state
- `PUT` validates input, deletes all existing, recreates in a transaction
---
## Related Files
| Purpose | Path |
|---------|------|
| Prisma schema | `prisma/schema.prisma` |
| Env template | `.env.example` |
| Docker services | `docker-compose.yml` |
| Dev seed data | `database/seeds/001_sample_data.sql` |
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
apps: [
{
name: 'meshkee-api',
script: 'dist/main.js',
cwd: __dirname,
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
},
max_memory_restart: '512M',
time: true,
},
],
};
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+6487
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "meshkee-cms-api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"prisma:generate": "prisma generate",
"prisma:pull": "prisma db pull"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1083.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "0.33.5"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"prisma": "^6.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.0"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1125
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './auth/auth.module';
import { BusinessTeamModule } from './business-team/business-team.module';
import { BusinessAdminModule } from './business-admin/business-admin.module';
import { UsersModule } from './users/users.module';
import { RolesModule } from './roles/roles.module';
import { TenantModule } from './tenant/tenant.module';
import { StorageModule } from './storage/storage.module';
import { MediaModule } from './media/media.module';
import { DomainAdminModule } from './domain-admin/domain-admin.module';
import { CategoriesModule } from './categories/categories.module';
import { ProductsModule } from './products/products.module';
import { BlogsModule } from './blogs/blogs.module';
import { PortfoliosModule } from './portfolios/portfolios.module';
import { CommentsModule } from './comments/comments.module';
import { CitiesModule } from './cities/cities.module';
import { ExpertReviewsModule } from './expert-reviews/expert-reviews.module';
import { BusinessSettingsModule } from './business-settings/business-settings.module';
import { BusinessProfileModule } from './business-profile/business-profile.module';
import { StoreModule } from './store/store.module';
import { CartModule } from './cart/cart.module';
import { OrdersModule } from './orders/orders.module';
import { CustomersModule } from './customers/customers.module';
import { ShoppingCardsModule } from './shopping-cards/shopping-cards.module';
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
import { FavoritesModule } from './favorites/favorites.module';
import { BrandsModule } from './brands/brands.module';
import { WebsiteModule } from './website/website.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
RedisModule,
AuthModule,
BusinessTeamModule,
BusinessAdminModule,
UsersModule,
RolesModule,
TenantModule,
StorageModule,
MediaModule,
DomainAdminModule,
CategoriesModule,
ProductsModule,
BlogsModule,
PortfoliosModule,
CommentsModule,
ExpertReviewsModule,
BusinessSettingsModule,
BusinessProfileModule,
CitiesModule,
StoreModule,
CartModule,
OrdersModule,
CustomersModule,
ShoppingCardsModule,
ContactSubmissionsModule,
FavoritesModule,
BrandsModule,
WebsiteModule,
],
})
export class AppModule {}
+108
View File
@@ -0,0 +1,108 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
import { SendOtpDto } from './dto/send-otp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AuthUser } from './auth.types';
import { UserAddressesService } from './user-addresses.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userAddresses: UserAddressesService,
) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('refresh')
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.authService.me(user);
}
@Patch('profile')
@UseGuards(JwtAuthGuard)
updateProfile(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) {
return this.authService.updateProfile(user, dto);
}
@Post('change-password')
@UseGuards(JwtAuthGuard)
changePassword(@CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto) {
return this.authService.changePassword(user, dto);
}
@Post('send-otp')
sendOtp(@Body() dto: SendOtpDto) {
return this.authService.sendOtp(dto.cellNumber);
}
@Post('verify-otp')
verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto.cellNumber, dto.code);
}
@Get('addresses')
@UseGuards(JwtAuthGuard)
listAddresses(@CurrentUser() user: AuthUser) {
return this.userAddresses.list(user);
}
@Post('addresses')
@UseGuards(JwtAuthGuard)
createAddress(
@CurrentUser() user: AuthUser,
@Body() dto: UpsertUserAddressDto,
) {
return this.userAddresses.create(user, dto);
}
@Patch('addresses/:addressId')
@UseGuards(JwtAuthGuard)
updateAddress(
@CurrentUser() user: AuthUser,
@Param('addressId') addressId: string,
@Body() dto: UpsertUserAddressDto,
) {
return this.userAddresses.update(user, addressId, dto);
}
@Delete('addresses/:addressId')
@UseGuards(JwtAuthGuard)
removeAddress(
@CurrentUser() user: AuthUser,
@Param('addressId') addressId: string,
) {
return this.userAddresses.remove(user, addressId);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../prisma/prisma.module';
import { TenantModule } from '../tenant/tenant.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { BusinessPermissionGuard } from './guards/business-permission.guard';
import { PermissionsService } from './permissions.service';
import { SmsService } from './sms.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UserAddressesService } from './user-addresses.service';
@Module({
imports: [
PrismaModule,
TenantModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
UserAddressesService,
SmsService,
PermissionsService,
BusinessPermissionGuard,
JwtStrategy,
],
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
})
export class AuthModule {}
+467
View File
@@ -0,0 +1,467 @@
import {
BadRequestException,
ConflictException,
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { TenantService } from '../tenant/tenant.service';
import {
AuthJwtPayload,
AuthUser,
DashboardType,
UserProfile,
resolvePrimaryRole,
} from './auth.types';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { PermissionsService } from './permissions.service';
import { parseUserProfile } from './profile.util';
import { SmsService } from './sms.service';
const OTP_TTL_SECONDS = 300;
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly redis: RedisService,
private readonly sms: SmsService,
private readonly tenant: TenantService,
private readonly permissions: PermissionsService,
) {}
async register(dto: RegisterDto) {
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
const passwordHash = await bcrypt.hash(dto.password, 10);
const smsEnabled = this.sms.isEnabled();
const customerRole = await this.prisma.role.findUnique({
where: { slug: 'customer' },
});
if (!customerRole) {
throw new Error('Customer role is missing. Run database migrations first.');
}
const existingUser = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
include: {
businessCustomers: { where: { businessId: business.id } },
},
});
if (existingUser?.businessCustomers.length) {
throw new ConflictException(
'This cell number is already registered on this website',
);
}
const user = await this.prisma.$transaction(async (tx) => {
const account =
existingUser ??
(await tx.user.create({
data: {
cellNumber: dto.cellNumber,
passwordHash,
email: dto.email,
firstName: dto.firstName,
lastName: dto.lastName,
cellVerifiedAt: smsEnabled ? null : new Date(),
},
}));
if (existingUser) {
const passwordValid = await bcrypt.compare(
dto.password,
existingUser.passwordHash,
);
if (!passwordValid) {
throw new ConflictException(
'Cell number exists on another account. Use login or reset password.',
);
}
}
await tx.businessCustomer.create({
data: {
businessId: business.id,
userId: account.id,
},
});
const hasCustomerRole = await tx.userRole.findUnique({
where: {
userId_roleId: {
userId: account.id,
roleId: customerRole.id,
},
},
});
if (!hasCustomerRole) {
await tx.userRole.create({
data: {
userId: account.id,
roleId: customerRole.id,
},
});
}
return account;
});
const authUser = await this.getAuthUser(user.id);
const tokens = await this.issueTokens(authUser);
return {
message: smsEnabled
? 'Registration successful. Please verify your cell number with OTP.'
: 'Registration successful. SMS verification is disabled — account auto-verified.',
smsEnabled,
user: this.serializeUser(authUser),
registeredBusiness: {
id: business.id,
name: business.name,
slug: business.slug,
},
...tokens,
};
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid cell number or password');
}
const passwordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordValid) {
throw new UnauthorizedException('Invalid cell number or password');
}
if (this.sms.isEnabled() && !user.cellVerifiedAt) {
throw new UnauthorizedException(
'Cell number is not verified. Please complete OTP verification.',
);
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const authUser = await this.getAuthUser(user.id);
const tokens = await this.issueTokens(authUser);
return {
message: 'Login successful',
user: this.serializeUser(authUser),
...tokens,
};
}
async refresh(refreshToken: string) {
let payload: AuthJwtPayload;
try {
payload = await this.jwt.verifyAsync<AuthJwtPayload>(refreshToken, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
const authUser = await this.getAuthUser(BigInt(payload.sub));
const tokens = await this.issueTokens(authUser);
return {
message: 'Token refreshed',
user: this.serializeUser(authUser),
...tokens,
};
}
async me(user: AuthUser) {
return { user: this.serializeUser(user) };
}
async updateProfile(user: AuthUser, dto: UpdateProfileDto) {
const currentProfile = user.profile;
const nextProfile: UserProfile = {
...currentProfile,
about: dto.about ?? currentProfile.about,
city: dto.city ?? currentProfile.city,
address: dto.address ?? currentProfile.address,
landline: dto.landline ?? currentProfile.landline,
backupPhone: dto.backupPhone ?? currentProfile.backupPhone,
postalCode: dto.postalCode ?? currentProfile.postalCode,
instagram: dto.instagram ?? currentProfile.instagram,
telegramId: dto.telegramId ?? currentProfile.telegramId,
linkedin: dto.linkedin ?? currentProfile.linkedin,
};
await this.prisma.user.update({
where: { id: user.id },
data: {
firstName: dto.firstName ?? user.firstName,
lastName: dto.lastName ?? user.lastName,
email: dto.email ?? user.email,
profile: nextProfile as object,
},
});
const authUser = await this.getAuthUser(user.id);
return {
message: 'Profile updated successfully',
user: this.serializeUser(authUser),
};
}
async changePassword(user: AuthUser, dto: ChangePasswordDto) {
const account = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!account) {
throw new UnauthorizedException('User not found');
}
const passwordValid = await bcrypt.compare(
dto.currentPassword,
account.passwordHash,
);
if (!passwordValid) {
throw new BadRequestException('Current password is incorrect');
}
if (dto.currentPassword === dto.newPassword) {
throw new BadRequestException(
'New password must be different from the current password',
);
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: user.id },
data: { passwordHash },
});
return { message: 'Password changed successfully' };
}
async sendOtp(cellNumber: string) {
if (!this.sms.isEnabled()) {
return {
enabled: false,
message:
'SMS verification is currently disabled. Register and login work without OTP.',
};
}
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
const code = this.generateOtpCode();
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendVerificationCode(cellNumber, code);
} catch {
throw new ServiceUnavailableException(
'SMS provider is not configured yet',
);
}
return {
enabled: true,
message: 'Verification code sent',
expiresInSeconds: OTP_TTL_SECONDS,
};
}
async verifyOtp(cellNumber: string, code: string) {
if (!this.sms.isEnabled()) {
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
if (!user.cellVerifiedAt) {
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
}
return {
enabled: false,
verified: true,
message: 'SMS verification is disabled — cell number marked as verified.',
};
}
const storedCode = await this.redis.getOtp(cellNumber);
if (!storedCode || storedCode !== code) {
throw new UnauthorizedException('Invalid or expired verification code');
}
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
await this.redis.deleteOtp(cellNumber);
return {
enabled: true,
verified: true,
message: 'Cell number verified successfully',
};
}
private async getAuthUser(userId: bigint): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
userRoles: { include: { role: true } },
businessUsers: { include: { business: true } },
businessCustomers: { include: { business: true } },
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
const roles = user.userRoles.map((ur) => ur.role.slug);
const businesses = await this.permissions.getBusinessMemberships(user.id);
const customerBusinesses = user.businessCustomers.map((bc) => ({
id: bc.business.id,
name: bc.business.name,
slug: bc.business.slug,
}));
return {
id: user.id,
cellNumber: user.cellNumber,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
cellVerifiedAt: user.cellVerifiedAt,
roles,
dashboard: this.resolveDashboard(roles, businesses.length),
profile: parseUserProfile(user.profile),
businesses,
customerBusinesses,
};
}
private resolveDashboard(roles: string[], businessCount: number): DashboardType {
if (roles.includes('super_admin')) {
return 'super_admin';
}
if (
roles.includes('business_owner') ||
roles.includes('business_staff') ||
roles.includes('owner') ||
businessCount > 0
) {
return 'business';
}
return 'customer';
}
private async issueTokens(user: AuthUser) {
const accessPayload: AuthJwtPayload = {
sub: user.id.toString(),
cellNumber: user.cellNumber,
roles: user.roles,
dashboard: user.dashboard,
type: 'access',
};
const refreshPayload: AuthJwtPayload = {
sub: user.id.toString(),
cellNumber: user.cellNumber,
roles: user.roles,
dashboard: user.dashboard,
type: 'refresh',
};
const accessExpiresIn = this.config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m');
const refreshExpiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(accessPayload, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
}),
this.jwt.signAsync(refreshPayload, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: refreshExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
}),
]);
return { accessToken, refreshToken };
}
private serializeUser(user: AuthUser) {
const primaryRole = resolvePrimaryRole(user.roles);
return {
id: user.id,
cellNumber: user.cellNumber,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
cellVerifiedAt: user.cellVerifiedAt,
roles: user.roles,
dashboard: user.dashboard,
primaryRole: primaryRole.slug,
roleLabel: primaryRole.label,
isSuperAdmin: primaryRole.isSuperAdmin,
profile: user.profile,
businesses: user.businesses,
customerBusinesses: user.customerBusinesses,
};
}
private generateOtpCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
}
+98
View File
@@ -0,0 +1,98 @@
export type DashboardType = 'super_admin' | 'business' | 'customer';
export interface AuthJwtPayload {
sub: string;
cellNumber: string;
roles: string[];
dashboard: DashboardType;
type: 'access' | 'refresh';
}
export interface BusinessMembership {
id: bigint;
name: string;
slug: string;
isOwner: boolean;
teamRole: string | null;
permissions: string[];
}
export interface UserProfile {
about: string;
city: string;
address: string;
landline: string;
backupPhone: string;
postalCode: string;
instagram: string;
telegramId: string;
linkedin: string;
}
export interface AuthUser {
id: bigint;
cellNumber: string;
email: string | null;
firstName: string | null;
lastName: string | null;
cellVerifiedAt: Date | null;
roles: string[];
dashboard: DashboardType;
profile: UserProfile;
businesses: BusinessMembership[];
customerBusinesses: { id: bigint; name: string; slug: string }[];
}
/** Roles a business owner can assign to team members */
export const ASSIGNABLE_TEAM_ROLES = ['admin', 'editor', 'viewer'] as const;
export type AssignableTeamRole = (typeof ASSIGNABLE_TEAM_ROLES)[number];
/** Global roles a super admin can assign to users */
export const ASSIGNABLE_GLOBAL_ROLES = [
'super_admin',
'business_owner',
'customer',
] as const;
export type AssignableGlobalRole = (typeof ASSIGNABLE_GLOBAL_ROLES)[number];
export const ROLE_LABELS: Record<string, string> = {
super_admin: 'Super Admin',
business_owner: 'Business Owner',
business_staff: 'Business Staff',
customer: 'Customer',
owner: 'Business Owner',
admin: 'Admin',
editor: 'Editor',
viewer: 'Viewer',
};
/** Highest global role for UI display (badge in header). */
export function resolvePrimaryRole(roles: string[]): {
slug: string;
label: string;
isSuperAdmin: boolean;
} {
if (roles.includes('super_admin')) {
return { slug: 'super_admin', label: ROLE_LABELS.super_admin, isSuperAdmin: true };
}
if (roles.includes('business_owner') || roles.includes('owner')) {
return {
slug: 'business_owner',
label: ROLE_LABELS.business_owner,
isSuperAdmin: false,
};
}
if (roles.includes('business_staff')) {
return {
slug: 'business_staff',
label: ROLE_LABELS.business_staff,
isSuperAdmin: false,
};
}
if (roles.includes('customer')) {
return { slug: 'customer', label: ROLE_LABELS.customer, isSuperAdmin: false };
}
const slug = roles[0] ?? 'customer';
return { slug, label: ROLE_LABELS[slug] ?? 'User', isSuperAdmin: false };
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthUser } from '../auth.types';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
return request.user;
},
);
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const BUSINESS_PERMISSION_KEY = 'business_permission';
export const RequireBusinessPermission = (permission: string) =>
SetMetadata(BUSINESS_PERMISSION_KEY, permission);
+10
View File
@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword!: string;
@IsString()
@MinLength(8, { message: 'newPassword must be at least 8 characters' })
newPassword!: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, Matches, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@MinLength(8)
password!: string;
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken!: string;
}
+30
View File
@@ -0,0 +1,30 @@
import { IsEmail, IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class RegisterDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@MinLength(8, { message: 'password must be at least 8 characters' })
password!: string;
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsOptional()
@IsEmail()
email?: string;
/** Domain of the business website (e.g. shop-a.local). Resolves tenant for customer registration. */
@IsString()
@MinLength(3)
domain!: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { IsString, Matches } from 'class-validator';
export class SendOtpDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
}
+63
View File
@@ -0,0 +1,63 @@
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(100)
firstName?: string;
@IsOptional()
@IsString()
@MaxLength(100)
lastName?: string;
@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
about?: string;
@IsOptional()
@IsString()
@MaxLength(100)
city?: string;
@IsOptional()
@IsString()
@MaxLength(255)
address?: string;
@IsOptional()
@IsString()
@MaxLength(30)
landline?: string;
@IsOptional()
@IsString()
@MaxLength(30)
backupPhone?: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(100)
instagram?: string;
@IsOptional()
@IsString()
@MaxLength(100)
telegramId?: string;
@IsOptional()
@IsString()
@MaxLength(255)
linkedin?: string;
}
+33
View File
@@ -0,0 +1,33 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpsertUserAddressDto {
@IsOptional()
@IsString()
@MaxLength(100)
label?: string;
@IsString()
@MinLength(1)
@MaxLength(100)
province!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
city!: string;
@IsString()
@MinLength(1)
@MaxLength(500)
address!: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(30)
landline?: string;
}
+14
View File
@@ -0,0 +1,14 @@
import { IsString, Length, Matches } from 'class-validator';
export class VerifyOtpDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
}
@@ -0,0 +1,53 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthUser } from '../auth.types';
import { BUSINESS_PERMISSION_KEY } from '../decorators/require-business-permission.decorator';
import { PermissionsService } from '../permissions.service';
@Injectable()
export class BusinessPermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissions: PermissionsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.get<string>(
BUSINESS_PERMISSION_KEY,
context.getHandler(),
);
if (!permission) {
return true;
}
const request = context.switchToHttp().getRequest<{
user: AuthUser;
params: { businessId?: string };
}>();
const businessIdRaw = request.params.businessId;
if (!businessIdRaw) {
throw new ForbiddenException('Business context is required');
}
const allowed = await this.permissions.hasBusinessPermission(
request.user.id,
BigInt(businessIdRaw),
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
return true;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+98
View File
@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PermissionsService {
constructor(private readonly prisma: PrismaService) {}
async isSuperAdmin(userId: bigint): Promise<boolean> {
const count = await this.prisma.userRole.count({
where: {
userId,
role: { slug: 'super_admin' },
},
});
return count > 0;
}
async getBusinessMemberships(userId: bigint) {
const memberships = await this.prisma.businessUser.findMany({
where: { userId },
include: { business: true, role: true },
});
return Promise.all(
memberships.map(async (m) => ({
id: m.business.id,
name: m.business.name,
slug: m.business.slug,
isOwner: m.isOwner,
teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null,
permissions: await this.getPermissionsForBusiness(userId, m.businessId),
})),
);
}
async getPermissionsForBusiness(
userId: bigint,
businessId: bigint,
): Promise<string[]> {
if (await this.isSuperAdmin(userId)) {
return this.getAllPermissionSlugs();
}
const membership = await this.prisma.businessUser.findUnique({
where: {
businessId_userId: { businessId, userId },
},
include: {
role: {
include: {
rolePermissions: { include: { permission: true } },
},
},
},
});
if (!membership) {
return [];
}
if (membership.isOwner) {
return this.getRolePermissionSlugs('business_owner');
}
if (!membership.role) {
return [];
}
return membership.role.rolePermissions.map((rp) => rp.permission.slug);
}
async hasBusinessPermission(
userId: bigint,
businessId: bigint,
permission: string,
): Promise<boolean> {
const permissions = await this.getPermissionsForBusiness(userId, businessId);
return permissions.includes(permission);
}
private async getRolePermissionSlugs(roleSlug: string): Promise<string[]> {
const role = await this.prisma.role.findUnique({
where: { slug: roleSlug },
include: {
rolePermissions: { include: { permission: true } },
},
});
return role?.rolePermissions.map((rp) => rp.permission.slug) ?? [];
}
private async getAllPermissionSlugs(): Promise<string[]> {
const permissions = await this.prisma.permission.findMany({
select: { slug: true },
});
return permissions.map((p) => p.slug);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { UserProfile } from './auth.types';
export function parseUserProfile(value: unknown): UserProfile {
const source =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
about: typeof source.about === 'string' ? source.about : '',
city: typeof source.city === 'string' ? source.city : '',
address: typeof source.address === 'string' ? source.address : '',
landline: typeof source.landline === 'string' ? source.landline : '',
backupPhone: typeof source.backupPhone === 'string' ? source.backupPhone : '',
postalCode: typeof source.postalCode === 'string' ? source.postalCode : '',
instagram: typeof source.instagram === 'string' ? source.instagram : '',
telegramId: typeof source.telegramId === 'string' ? source.telegramId : '',
linkedin: typeof source.linkedin === 'string' ? source.linkedin : '',
};
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
if (!this.isEnabled()) {
this.logger.warn(
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
);
return;
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS verification code to ${cellNumber}`);
throw new Error('SMS provider is not configured yet');
}
async sendMessage(cellNumber: string, message: string): Promise<void> {
if (!this.isEnabled()) {
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
return;
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`);
throw new Error('SMS provider is not configured yet');
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import {
AuthJwtPayload,
AuthUser,
DashboardType,
} from '../auth.types';
import { PermissionsService } from '../permissions.service';
import { parseUserProfile } from '../profile.util';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: AuthJwtPayload): Promise<AuthUser> {
if (payload.type !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.prisma.user.findUnique({
where: { id: BigInt(payload.sub) },
include: {
userRoles: { include: { role: true } },
businessCustomers: { include: { business: true } },
},
});
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
const roles = user.userRoles.map((ur) => ur.role.slug);
const businesses = await this.permissions.getBusinessMemberships(user.id);
const customerBusinesses = user.businessCustomers.map((bc) => ({
id: bc.business.id,
name: bc.business.name,
slug: bc.business.slug,
}));
return {
id: user.id,
cellNumber: user.cellNumber,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
cellVerifiedAt: user.cellVerifiedAt,
roles,
dashboard: this.resolveDashboard(roles, businesses.length),
profile: parseUserProfile(user.profile),
businesses,
customerBusinesses,
};
}
private resolveDashboard(roles: string[], businessCount: number): DashboardType {
if (roles.includes('super_admin')) {
return 'super_admin';
}
if (
roles.includes('business_owner') ||
roles.includes('business_staff') ||
roles.includes('owner') ||
businessCount > 0
) {
return 'business';
}
return 'customer';
}
}
+109
View File
@@ -0,0 +1,109 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from './auth.types';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
@Injectable()
export class UserAddressesService {
constructor(private readonly prisma: PrismaService) {}
async list(actor: AuthUser) {
const items = await this.prisma.address.findMany({
where: { userId: actor.id, businessId: null },
orderBy: { createdAt: 'asc' },
});
return { items: items.map((item) => this.serialize(item)) };
}
async create(actor: AuthUser, dto: UpsertUserAddressDto) {
const created = await this.prisma.address.create({
data: {
userId: actor.id,
label: dto.label?.trim() || null,
province: dto.province.trim(),
city: dto.city.trim(),
address: dto.address.trim(),
postalCode: dto.postalCode?.trim() || null,
landline: dto.landline?.trim() || null,
},
});
return { address: this.serialize(created) };
}
async update(
actor: AuthUser,
addressIdRaw: string,
dto: UpsertUserAddressDto,
) {
const address = await this.findOwnedAddress(actor, addressIdRaw);
const updated = await this.prisma.address.update({
where: { id: address.id },
data: {
label: dto.label?.trim() || null,
province: dto.province.trim(),
city: dto.city.trim(),
address: dto.address.trim(),
postalCode: dto.postalCode?.trim() || null,
landline: dto.landline?.trim() || null,
},
});
return { address: this.serialize(updated) };
}
async remove(actor: AuthUser, addressIdRaw: string) {
const address = await this.findOwnedAddress(actor, addressIdRaw);
await this.prisma.address.delete({
where: { id: address.id },
});
return { message: 'Address removed.' };
}
private async findOwnedAddress(actor: AuthUser, addressIdRaw: string) {
const address = await this.prisma.address.findFirst({
where: {
id: BigInt(addressIdRaw),
userId: actor.id,
businessId: null,
},
});
if (!address) {
throw new NotFoundException('Address not found');
}
return address;
}
private serialize(address: {
id: bigint;
label: string | null;
province: string;
city: string;
address: string;
postalCode: string | null;
landline: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: address.id.toString(),
label: address.label,
province: address.province,
city: address.city,
address: address.address,
postalCode: address.postalCode,
landline: address.landline,
createdAt: address.createdAt,
updatedAt: address.updatedAt,
};
}
}
+120
View File
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BlogsService } from './blogs.service';
import {
CreateBlogCommentDto,
CreateBlogDto,
ListBlogsDto,
ListPublicBlogsDto,
UpdateBlogDto,
} from './dto/blog.dto';
@Controller('businesses/:businessId/blogs')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class BlogsController {
constructor(private readonly service: BlogsService) {}
@Get()
@RequireBusinessPermission('blogs.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListBlogsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':blogId')
@RequireBusinessPermission('blogs.read')
getOne(
@Param('businessId') businessId: string,
@Param('blogId') blogId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, blogId, user);
}
@Post()
@RequireBusinessPermission('blogs.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateBlogDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Patch(':blogId')
@RequireBusinessPermission('blogs.update')
update(
@Param('businessId') businessId: string,
@Param('blogId') blogId: string,
@Body() dto: UpdateBlogDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, blogId, dto, user);
}
@Delete(':blogId')
@RequireBusinessPermission('blogs.delete')
remove(
@Param('businessId') businessId: string,
@Param('blogId') blogId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, blogId, user);
}
@Get(':blogId/comments')
@RequireBusinessPermission('comments.read')
listComments(
@Param('businessId') businessId: string,
@Param('blogId') blogId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listCommentsAdmin(businessId, blogId, user);
}
}
@Controller('tenants/:host/blogs')
export class PublicBlogsController {
constructor(private readonly service: BlogsService) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
return this.service.listPublic(host, query);
}
@Get(':blogId/comments')
listComments(@Param('host') host: string, @Param('blogId') blogId: string) {
return this.service.listCommentsPublic(host, blogId);
}
@Post(':blogId/comments')
createComment(
@Param('host') host: string,
@Param('blogId') blogId: string,
@Body() dto: CreateBlogCommentDto,
) {
return this.service.createCommentPublic(host, blogId, dto);
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { TenantModule } from '../tenant/tenant.module';
import { BlogsController, PublicBlogsController } from './blogs.controller';
import { BlogsService } from './blogs.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, TenantModule],
controllers: [BlogsController, PublicBlogsController],
providers: [BlogsService],
})
export class BlogsModule {}
+733
View File
@@ -0,0 +1,733 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContentStatus,
MediaEntityType,
Prisma,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreateBlogCommentDto,
CreateBlogDto,
ListBlogsDto,
ListPublicBlogsDto,
UpdateBlogDto,
} from './dto/blog.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'blog'
);
}
type BlogWithRelations = Prisma.blogsGetPayload<{
include: {
media: true;
users: { select: { id: true; firstName: true; lastName: true; email: true } };
};
}>;
const blogInclude = {
media: true,
users: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
} satisfies Prisma.blogsInclude;
@Injectable()
export class BlogsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
) {}
async list(businessIdRaw: string, query: ListBlogsDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'blogs.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, query);
const [items, total] = await Promise.all([
this.prisma.blogs.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: blogInclude,
}),
this.prisma.blogs.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) => this.serializeBlog(item, { includeComments: true })),
);
return { items: serialized, total, page, pageSize };
}
async getOne(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const blogId = BigInt(blogIdRaw);
await this.assertPermission(businessId, actor.id, 'blogs.read');
const blog = await this.findBlogOrThrow(businessId, blogId);
return { blog: await this.serializeBlog(blog, { includeComments: true }) };
}
async create(businessIdRaw: string, dto: CreateBlogDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'blogs.create');
const slug = await this.ensureUniqueSlug(
businessId,
dto.slug ?? slugify(dto.title),
);
const status = dto.status ?? ContentStatus.draft;
const featuredMediaId = dto.featuredMediaId
? BigInt(dto.featuredMediaId)
: null;
if (featuredMediaId) {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
await this.assertAuthorBelongsToBusiness(businessId, authorId);
if (dto.categoryId) {
await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId));
}
const created = await this.prisma.$transaction(async (tx) => {
const blog = await tx.blogs.create({
data: {
business_id: businessId,
author_id: authorId,
title: dto.title.trim(),
slug,
excerpt: dto.abstract?.trim() || null,
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
post_type: dto.type,
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
},
include: blogInclude,
});
if (dto.categoryId) {
await tx.categoryAssignment.create({
data: {
businessId,
categoryId: BigInt(dto.categoryId),
entityType: MediaEntityType.blog,
entityId: blog.id,
},
});
}
return blog;
});
return {
message: 'Blog post created successfully',
blog: await this.serializeBlog(created),
};
}
async update(
businessIdRaw: string,
blogIdRaw: string,
dto: UpdateBlogDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const blogId = BigInt(blogIdRaw);
await this.assertPermission(businessId, actor.id, 'blogs.update');
const existing = await this.prisma.blogs.findFirst({
where: { id: blogId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Blog post not found');
}
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueSlug(businessId, dto.slug, blogId);
} else if (dto.title && dto.title !== existing.title) {
slug = await this.ensureUniqueSlug(businessId, slugify(dto.title), blogId);
}
let featuredMediaId: bigint | null | undefined = undefined;
if (dto.featuredMediaId !== undefined) {
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
featuredMediaId = null;
} else {
featuredMediaId = BigInt(dto.featuredMediaId);
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
}
let authorId: bigint | null | undefined = undefined;
if (dto.authorId !== undefined) {
if (dto.authorId === null || dto.authorId === '') {
authorId = null;
} else {
authorId = BigInt(dto.authorId);
await this.assertAuthorBelongsToBusiness(businessId, authorId);
}
}
const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata);
const nextContent = { ...existingContent };
if (dto.mainTextHtml !== undefined) {
nextContent.html = dto.mainTextHtml ?? '';
}
const nextMetadata = { ...existingMetadata };
if (dto.tags !== undefined) {
nextMetadata.tags = dto.tags;
}
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
if (dto.status === ContentStatus.published && existing.status !== ContentStatus.published) {
publishedAt = new Date();
}
if (dto.status !== ContentStatus.published) {
publishedAt = null;
}
}
const updated = await this.prisma.$transaction(async (tx) => {
const blog = await tx.blogs.update({
where: { id: blogId },
data: {
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.abstract !== undefined
? { excerpt: dto.abstract?.trim() || null }
: {}),
...(dto.type !== undefined ? { post_type: dto.type } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined ? { featured_media_id: featuredMediaId } : {}),
...(authorId !== undefined ? { author_id: authorId } : {}),
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
include: blogInclude,
});
if (dto.categoryId !== undefined) {
await tx.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
},
});
if (dto.categoryId) {
const categoryId = BigInt(dto.categoryId);
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
await tx.categoryAssignment.create({
data: {
businessId,
categoryId,
entityType: MediaEntityType.blog,
entityId: blogId,
},
});
}
}
return blog;
});
return {
message: 'Blog post updated successfully',
blog: await this.serializeBlog(updated),
};
}
async remove(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const blogId = BigInt(blogIdRaw);
await this.assertPermission(businessId, actor.id, 'blogs.delete');
const existing = await this.prisma.blogs.findFirst({
where: { id: blogId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Blog post not found');
}
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
},
}),
this.prisma.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
},
}),
this.prisma.blogs.delete({ where: { id: blogId } }),
]);
return { message: 'Blog post deleted successfully' };
}
async listPublic(host: string, query: ListPublicBlogsDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, {
...query,
status: ContentStatus.published,
});
const [items, total] = await Promise.all([
this.prisma.blogs.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: blogInclude,
}),
this.prisma.blogs.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializeBlog(item, { includeComments: true, approvedCommentsOnly: true }),
),
);
return { items: serialized, total, page, pageSize };
}
async getPublicBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const blog = await this.prisma.blogs.findFirst({
where: {
business_id: businessId,
slug,
status: ContentStatus.published,
},
include: blogInclude,
});
if (!blog) {
throw new NotFoundException('Blog post not found');
}
return {
blog: await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
}),
};
}
async listCommentsPublic(host: string, blogIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const blogId = BigInt(blogIdRaw);
await this.assertPublishedBlogExists(businessId, blogId);
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
isApproved: true,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
async createCommentPublic(
host: string,
blogIdRaw: string,
dto: CreateBlogCommentDto,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const blogId = BigInt(blogIdRaw);
await this.assertPublishedBlogExists(businessId, blogId);
const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId);
const approvedAt = autoApprove ? new Date() : null;
const created = await this.prisma.comment.create({
data: {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
authorName: dto.authorName.trim(),
authorEmail: dto.authorEmail?.trim() || null,
text: dto.text.trim(),
isApproved: autoApprove,
approvedAt,
},
include: { approver: true },
});
return {
comment: this.serializeComment(created),
message: autoApprove
? 'Comment submitted and is approved'
: 'Comment submitted and is pending approval',
};
}
async listCommentsAdmin(
businessIdRaw: string,
blogIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const blogId = BigInt(blogIdRaw);
await this.assertPermission(businessId, actor.id, 'comments.read');
const blog = await this.prisma.blogs.findFirst({
where: { id: blogId, business_id: businessId },
select: { id: true },
});
if (!blog) {
throw new NotFoundException('Blog post not found');
}
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
private async buildWhere(
businessId: bigint,
query: (ListBlogsDto | ListPublicBlogsDto) & { status?: ContentStatus },
): Promise<Prisma.blogsWhereInput> {
let entityIds: bigint[] | undefined;
if (query.categoryId) {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
categoryId: BigInt(query.categoryId),
entityType: MediaEntityType.blog,
},
select: { entityId: true },
});
entityIds = assignments.map((item) => item.entityId);
if (entityIds.length === 0) {
return { id: { in: [] } };
}
}
return {
business_id: businessId,
...(query.status ? { status: query.status } : {}),
...(query.type ? { post_type: query.type } : {}),
...(entityIds ? { id: { in: entityIds } } : {}),
...(query.title?.trim()
? {
title: { contains: query.title.trim(), mode: 'insensitive' },
}
: {}),
};
}
private async findBlogOrThrow(businessId: bigint, blogId: bigint) {
const blog = await this.prisma.blogs.findFirst({
where: { id: blogId, business_id: businessId },
include: blogInclude,
});
if (!blog) {
throw new NotFoundException('Blog post not found');
}
return blog;
}
private async assertPublishedBlogExists(businessId: bigint, blogId: bigint) {
const blog = await this.prisma.blogs.findFirst({
where: {
id: blogId,
business_id: businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!blog) {
throw new NotFoundException('Blog post not found');
}
}
private async serializeBlog(
blog: BlogWithRelations,
options: {
includeComments?: boolean;
approvedCommentsOnly?: boolean;
} = {},
) {
const content = this.asRecord(blog.content);
const metadata = this.asRecord(blog.metadata);
const [categoryAssignment, commentData] = await Promise.all([
this.prisma.categoryAssignment.findFirst({
where: {
businessId: blog.business_id,
entityType: MediaEntityType.blog,
entityId: blog.id,
},
include: { category: true },
}),
options.includeComments
? this.loadComments(blog.business_id, blog.id, options.approvedCommentsOnly)
: Promise.resolve({ commentCount: 0, comments: [] }),
]);
return {
id: blog.id.toString(),
businessId: blog.business_id.toString(),
title: blog.title,
slug: blog.slug,
type: blog.post_type,
abstract: blog.excerpt ?? '',
mainTextHtml: (content.html as string | undefined) ?? '',
status: blog.status,
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
authorId: blog.author_id?.toString() ?? null,
author: blog.users
? {
id: blog.users.id.toString(),
firstName: blog.users.firstName,
lastName: blog.users.lastName,
email: blog.users.email,
}
: null,
titleImageUrl: blog.media?.publicUrl ?? null,
featuredMediaId: blog.featured_media_id?.toString() ?? null,
commentCount: commentData.commentCount,
comments: commentData.comments,
publishedAt: blog.published_at,
createdAt: blog.created_at,
updatedAt: blog.updated_at,
};
}
private async loadComments(
businessId: bigint,
blogId: bigint,
approvedOnly?: boolean,
) {
const where: Prisma.CommentWhereInput = {
businessId,
entityType: MediaEntityType.blog,
entityId: blogId,
...(approvedOnly ? { isApproved: true } : {}),
};
const [commentCount, comments] = await Promise.all([
this.prisma.comment.count({ where }),
this.prisma.comment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: approvedOnly ? 50 : undefined,
include: { approver: true },
}),
]);
return {
commentCount,
comments: comments.map((item) => this.serializeComment(item)),
};
}
private serializeComment(
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
) {
return {
id: comment.id.toString(),
businessId: comment.businessId.toString(),
entityType: comment.entityType,
entityId: comment.entityId.toString(),
authorName: comment.authorName,
authorEmail: comment.authorEmail,
text: comment.text,
isApproved: comment.isApproved,
approvedAt: comment.approvedAt,
approvedBy: comment.approvedBy?.toString() ?? null,
approver: comment.approver
? {
id: comment.approver.id.toString(),
firstName: comment.approver.firstName,
lastName: comment.approver.lastName,
}
: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
private buildContent(mainTextHtml?: string) {
return {
html: mainTextHtml ?? '',
};
}
private buildMetadata(tags?: string[]) {
return {
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
};
}
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Media not found for this business');
}
}
private async assertCategoryBelongsToBusiness(
businessId: bigint,
categoryId: bigint,
) {
const category = await this.prisma.category.findFirst({
where: {
id: categoryId,
businessId,
entityType: MediaEntityType.blog,
isActive: true,
},
});
if (!category) {
throw new BadRequestException('Blog category not found for this business');
}
}
private async assertAuthorBelongsToBusiness(businessId: bigint, authorId: bigint) {
const member = await this.prisma.businessUser.findFirst({
where: { businessId, userId: authorId },
});
if (!member) {
throw new BadRequestException('Author must be a team member of this business');
}
}
private async ensureUniqueSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.blogs.findFirst({
where: {
business_id: businessId,
slug,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!existing) {
return slug;
}
suffix += 1;
slug = `${baseSlug}-${suffix}`;
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import { BlogPostType, ContentStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
export class ListBlogsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
@IsOptional()
@IsEnum(BlogPostType)
type?: BlogPostType;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
title?: string;
}
export class ListPublicBlogsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsEnum(BlogPostType)
type?: BlogPostType;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
title?: string;
}
export class CreateBlogDto {
@IsString()
@MinLength(2)
title!: string;
@IsEnum(BlogPostType)
type!: BlogPostType;
@IsOptional()
@IsString()
abstract?: string;
@IsOptional()
@IsString()
mainTextHtml?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
@IsOptional()
@IsString()
featuredMediaId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
authorId?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
}
export class UpdateBlogDto {
@IsOptional()
@IsString()
@MinLength(2)
title?: string;
@IsOptional()
@IsEnum(BlogPostType)
type?: BlogPostType;
@IsOptional()
@IsString()
abstract?: string | null;
@IsOptional()
@IsString()
mainTextHtml?: string | null;
@IsOptional()
@IsString()
categoryId?: string | null;
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
@IsOptional()
@IsString()
featuredMediaId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
authorId?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
}
export class CreateBlogCommentDto {
@IsString()
@MinLength(2)
authorName!: string;
@IsOptional()
@IsString()
authorEmail?: string;
@IsString()
@MinLength(1)
text!: string;
}
+75
View File
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BrandsService } from './brands.service';
import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto';
@Controller('businesses/:businessId/brands')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class BrandsController {
constructor(private readonly service: BrandsService) {}
@Get()
@RequireBusinessPermission('brands.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListBrandsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':brandId')
@RequireBusinessPermission('brands.read')
getOne(
@Param('businessId') businessId: string,
@Param('brandId') brandId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, brandId, user);
}
@Post()
@RequireBusinessPermission('brands.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateBrandDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Patch(':brandId')
@RequireBusinessPermission('brands.update')
update(
@Param('businessId') businessId: string,
@Param('brandId') brandId: string,
@Body() dto: UpdateBrandDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, brandId, dto, user);
}
@Delete(':brandId')
@RequireBusinessPermission('brands.delete')
remove(
@Param('businessId') businessId: string,
@Param('brandId') brandId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, brandId, user);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BrandsController } from './brands.controller';
import { BrandsService } from './brands.service';
@Module({
imports: [AuthModule],
controllers: [BrandsController],
providers: [BrandsService],
exports: [BrandsService],
})
export class BrandsModule {}
+284
View File
@@ -0,0 +1,284 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'brand'
);
}
type BrandWithImage = Prisma.BrandGetPayload<{
include: { imageMedia: true };
}>;
@Injectable()
export class BrandsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(businessIdRaw: string, query: ListBrandsDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where: Prisma.BrandWhereInput = {
businessId,
...(query.name?.trim()
? {
OR: [
{ nameEn: { contains: query.name.trim(), mode: 'insensitive' } },
{ nameFa: { contains: query.name.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.brand.findMany({
where,
orderBy: [{ sort_order: 'asc' }, { nameEn: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: { imageMedia: true },
}),
this.prisma.brand.count({ where }),
]);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
};
}
async getOne(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const brandId = BigInt(brandIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.read');
const brand = await this.findBrandOrThrow(businessId, brandId);
return { brand: this.serialize(brand) };
}
async create(businessIdRaw: string, dto: CreateBrandDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.create');
const slug = await this.ensureUniqueSlug(
businessId,
dto.slug ?? slugify(dto.nameEn),
);
let imageMediaId: bigint | null = null;
if (dto.imageMediaId) {
imageMediaId = BigInt(dto.imageMediaId);
await this.assertBrandImageMedia(businessId, imageMediaId);
}
const created = await this.prisma.brand.create({
data: {
businessId,
nameEn: dto.nameEn.trim(),
nameFa: dto.nameFa?.trim() || null,
imageMediaId,
about: dto.about?.trim() || null,
slug,
sort_order: dto.sortOrder ?? 0,
},
include: { imageMedia: true },
});
return {
message: 'Brand created successfully',
brand: this.serialize(created),
};
}
async update(
businessIdRaw: string,
brandIdRaw: string,
dto: UpdateBrandDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const brandId = BigInt(brandIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.update');
const existing = await this.findBrandOrThrow(businessId, brandId);
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueSlug(businessId, dto.slug, brandId);
} else if (dto.nameEn && dto.nameEn !== existing.nameEn) {
slug = await this.ensureUniqueSlug(
businessId,
slugify(dto.nameEn),
brandId,
);
}
let imageMediaId: bigint | null | undefined = undefined;
if (dto.imageMediaId !== undefined) {
if (dto.imageMediaId === null || dto.imageMediaId === '') {
imageMediaId = null;
} else {
imageMediaId = BigInt(dto.imageMediaId);
await this.assertBrandImageMedia(businessId, imageMediaId);
}
}
const updated = await this.prisma.brand.update({
where: { id: brandId },
data: {
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}),
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}),
...(dto.about !== undefined ? { about: dto.about?.trim() || null } : {}),
...(imageMediaId !== undefined ? { imageMediaId } : {}),
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
slug,
},
include: { imageMedia: true },
});
return {
message: 'Brand updated successfully',
brand: this.serialize(updated),
};
}
async remove(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const brandId = BigInt(brandIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.delete');
await this.findBrandOrThrow(businessId, brandId);
await this.prisma.brand.delete({ where: { id: brandId } });
return { message: 'Brand deleted successfully' };
}
async assertBrandBelongsToBusiness(businessId: bigint, brandId: bigint) {
const brand = await this.prisma.brand.findFirst({
where: { id: brandId, businessId },
});
if (!brand) {
throw new BadRequestException('Brand not found for this business');
}
}
serializeBrandSummary(brand: BrandWithImage | null) {
if (!brand) {
return null;
}
return this.serialize(brand);
}
private async findBrandOrThrow(businessId: bigint, brandId: bigint) {
const brand = await this.prisma.brand.findFirst({
where: { id: brandId, businessId },
include: { imageMedia: true },
});
if (!brand) {
throw new NotFoundException('Brand not found');
}
return brand;
}
private serialize(brand: BrandWithImage) {
return {
id: brand.id.toString(),
businessId: brand.businessId.toString(),
nameEn: brand.nameEn,
nameFa: brand.nameFa,
about: brand.about,
slug: brand.slug,
imageMediaId: brand.imageMediaId?.toString() ?? null,
imageUrl: brand.imageMedia?.publicUrl ?? null,
sortOrder: brand.sort_order,
createdAt: brand.createdAt,
updatedAt: brand.updatedAt,
};
}
private async assertBrandImageMedia(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
select: { id: true, mimeType: true },
});
if (!media) {
throw new BadRequestException('Brand image media not found for this business');
}
if (media.mimeType !== 'image/png') {
throw new BadRequestException('Brand image must be a PNG file');
}
}
private async ensureUniqueSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.brand.findFirst({
where: {
businessId,
slug,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!existing) {
return slug;
}
suffix += 1;
slug = `${baseSlug}-${suffix}`;
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
export class ListBrandsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
name?: string;
}
export class CreateBrandDto {
@IsString()
@MinLength(2)
nameEn!: string;
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
imageMediaId?: string;
@IsOptional()
@IsString()
about?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
}
export class UpdateBrandDto {
@IsOptional()
@IsString()
@MinLength(2)
nameEn?: string;
@IsOptional()
@IsString()
nameFa?: string | null;
@IsOptional()
@IsString()
imageMediaId?: string | null;
@IsOptional()
@IsString()
about?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
}
@@ -0,0 +1,94 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types';
import { ListBusinessesDto } from './dto/list-businesses.dto';
import { SearchBusinessesDto } from './dto/search-businesses.dto';
import { CreateBusinessDto } from './dto/create-business.dto';
import { AddDomainDto } from './dto/add-domain.dto';
import { UpdateDomainDto } from './dto/update-domain.dto';
import { DisableBusinessDto } from './dto/disable-business.dto';
import { UpdateBusinessDto } from './dto/update-business.dto';
import { BusinessAdminService } from './business-admin.service';
@Controller('businesses')
export class BusinessAdminController {
constructor(private readonly service: BusinessAdminService) {}
@Get('search')
@UseGuards(JwtAuthGuard)
search(@Query() query: SearchBusinessesDto, @CurrentUser() user: AuthUser) {
return this.service.search(query, user);
}
@Get()
@UseGuards(JwtAuthGuard)
list(@Query() query: ListBusinessesDto, @CurrentUser() user: AuthUser) {
return this.service.list(query, user);
}
@Get(':businessId/staff')
@UseGuards(JwtAuthGuard)
listStaff(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.listStaff(businessId, user);
}
@Get(':businessId')
@UseGuards(JwtAuthGuard)
getOne(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.getOne(businessId, user);
}
@Post()
@UseGuards(JwtAuthGuard)
create(@Body() dto: CreateBusinessDto, @CurrentUser() user: AuthUser) {
return this.service.create(dto, user);
}
@Patch(':businessId')
@UseGuards(JwtAuthGuard)
update(
@Param('businessId') businessId: string,
@Body() dto: UpdateBusinessDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, dto, user);
}
@Post(':businessId/domains')
@UseGuards(JwtAuthGuard)
addDomain(
@Param('businessId') businessId: string,
@Body() dto: AddDomainDto,
@CurrentUser() user: AuthUser,
) {
return this.service.addDomain(businessId, dto, user);
}
@Patch(':businessId/domains/:domainId')
@UseGuards(JwtAuthGuard)
updateDomain(
@Param('businessId') businessId: string,
@Param('domainId') domainId: string,
@Body() dto: UpdateDomainDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateDomain(businessId, domainId, dto, user);
}
@Patch(':businessId/disable')
@UseGuards(JwtAuthGuard)
disable(
@Param('businessId') businessId: string,
@Body() dto: DisableBusinessDto,
@CurrentUser() user: AuthUser,
) {
return this.service.disable(businessId, dto, user);
}
@Delete(':businessId')
@UseGuards(JwtAuthGuard)
remove(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.remove(businessId, user);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessCategoriesController } from './business-categories.controller';
import { BusinessCategoriesService } from './business-categories.service';
import { BusinessAdminController } from './business-admin.controller';
import { BusinessAdminService } from './business-admin.service';
@Module({
imports: [AuthModule],
controllers: [BusinessAdminController, BusinessCategoriesController],
providers: [BusinessAdminService, BusinessCategoriesService],
})
export class BusinessAdminModule {}
@@ -0,0 +1,646 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../auth/auth.types';
import { AddDomainDto } from './dto/add-domain.dto';
import { UpdateDomainDto } from './dto/update-domain.dto';
import { CreateBusinessDto } from './dto/create-business.dto';
import { DisableBusinessDto } from './dto/disable-business.dto';
import { UpdateBusinessDto } from './dto/update-business.dto';
import { ListBusinessesDto } from './dto/list-businesses.dto';
import { SearchBusinessesDto } from './dto/search-businesses.dto';
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
type BusinessRow = {
id: bigint;
name: string;
nameFa: string | null;
about: string | null;
slug: string;
createdAt: Date;
isActive: boolean;
domainId: bigint | null;
domain: string | null;
sslEnabled: boolean | null;
ownerUserId: bigint | null;
ownerName: string | null;
ownerCellNumber: string | null;
primaryColor: string | null;
};
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
@Injectable()
export class BusinessAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required');
}
}
async list(query: ListBusinessesDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const skip = (page - 1) * pageSize;
const nameLike = query.name ? `%${query.name.trim()}%` : null;
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
const where = Prisma.sql`
WHERE 1=1
${nameLike ? Prisma.sql`AND (b.name ILIKE ${nameLike} OR b.name_fa ILIKE ${nameLike})` : Prisma.empty}
${domainLike ? Prisma.sql`
AND EXISTS (
SELECT 1 FROM domains d
WHERE d.business_id = b.id AND d.host ILIKE ${domainLike}
)
` : Prisma.empty}
${categoryLike ? Prisma.sql`
AND EXISTS (
SELECT 1
FROM business_category_assignments bca
JOIN business_categories bc ON bc.id = bca.category_id
WHERE bca.business_id = b.id
AND (bc.slug ILIKE ${categoryLike} OR bc.name ILIKE ${categoryLike})
)
` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
SELECT
b.id AS "id",
b.name AS "name",
b.name_fa AS "nameFa",
b.about AS "about",
b.slug AS "slug",
b.created_at AS "createdAt",
b.is_active AS "isActive",
dom.id AS "domainId",
dom.host AS "domain",
dom.ssl_enabled AS "sslEnabled",
own."ownerUserId" AS "ownerUserId",
own."ownerName" AS "ownerName",
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled
FROM domains d
WHERE d.business_id = b.id
ORDER BY d.is_primary DESC, d.created_at DESC
LIMIT 1
) dom ON TRUE
LEFT JOIN LATERAL (
SELECT
u.id AS "ownerUserId",
(u.first_name || ' ' || u.last_name) AS "ownerName",
u.cell_number AS "ownerCellNumber"
FROM business_users bu
JOIN users u ON u.id = bu.user_id
WHERE bu.business_id = b.id AND bu.is_owner = TRUE
LIMIT 1
) own ON TRUE
${where}
ORDER BY b.created_at DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM businesses b
${where}
`),
]);
return {
items: items.map((item) => ({
...item,
primaryColor: normalizeBusinessPrimaryColorId(
item.primaryColor,
) as BusinessPrimaryColorId,
})),
total: totalRow[0]?.total ?? 0,
page,
pageSize,
};
}
async search(query: SearchBusinessesDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const q = query.q.trim();
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
const like = `%${q}%`;
const items = await this.prisma.$queryRaw<
{
id: bigint;
name: string;
nameFa: string | null;
slug: string;
}[]
>(Prisma.sql`
SELECT DISTINCT
b.id AS "id",
b.name AS "name",
b.name_fa AS "nameFa",
b.slug AS "slug"
FROM businesses b
LEFT JOIN domains d ON d.business_id = b.id
WHERE b.is_active = TRUE
AND (
b.name ILIKE ${like}
OR b.name_fa ILIKE ${like}
OR b.slug ILIKE ${like}
OR d.host ILIKE ${like}
)
ORDER BY b.name ASC
LIMIT ${limit}
`);
return {
items: items.map((business) => ({
id: business.id,
name: business.name,
nameFa: business.nameFa,
slug: business.slug,
label: this.formatBusinessLabel(business),
})),
};
}
async listStaff(businessIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const members = await this.prisma.businessUser.findMany({
where: { businessId },
include: {
user: {
select: {
id: true,
cellNumber: true,
firstName: true,
lastName: true,
email: true,
cellVerifiedAt: true,
isActive: true,
},
},
role: true,
inviter: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
});
return {
items: members.map((member) => ({
id: member.id,
userId: member.user.id,
cellNumber: member.user.cellNumber,
firstName: member.user.firstName,
lastName: member.user.lastName,
email: member.user.email,
isActive: member.user.isActive,
isVerified: member.user.cellVerifiedAt !== null,
isOwner: member.isOwner,
teamRole: member.isOwner ? 'business_owner' : member.role?.slug ?? null,
invitedBy: member.inviter,
createdAt: member.createdAt,
})),
};
}
async getOne(businessIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
include: {
categoryAssignments: {
include: { category: true },
},
businessUsers: {
where: { isOwner: true },
include: { user: true },
take: 1,
},
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
},
});
if (!business) {
throw new NotFoundException('Business not found');
}
return this.serializeBusiness(business);
}
async create(dto: CreateBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const slug = dto.slug?.trim() || slugify(dto.name);
if (!slug) {
throw new BadRequestException('Could not generate slug from name');
}
await this.assertSlugAvailable(slug);
await this.validateCategoryIds(dto.categoryIds);
const business = await this.prisma.$transaction(async (tx) => {
const owner = await this.createOwnerUser(tx, dto);
const created = await tx.business.create({
data: {
name: dto.name.trim(),
nameFa: dto.nameFa.trim(),
about: dto.about?.trim() ?? null,
slug,
},
});
await tx.businessCategoryAssignment.createMany({
data: dto.categoryIds.map((id) => ({
businessId: created.id,
categoryId: BigInt(id),
})),
});
await this.assignOwner(tx, created.id, owner.id, actor.id);
return created;
});
return this.getOne(business.id.toString(), actor);
}
async update(businessIdRaw: string, dto: UpdateBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const nextName = dto.name?.trim() ?? business.name;
const nextNameFa = dto.nameFa?.trim() ?? business.nameFa ?? business.name;
const nextSlug =
dto.slug?.trim() ?? (dto.name ? slugify(dto.name) : business.slug);
if (nextSlug !== business.slug) {
await this.assertSlugAvailable(nextSlug, businessId);
}
if (dto.categoryIds) {
await this.validateCategoryIds(dto.categoryIds);
}
if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) {
await this.findOwnerUser(dto.ownerUserId);
}
await this.prisma.$transaction(async (tx) => {
await tx.business.update({
where: { id: businessId },
data: {
name: nextName,
nameFa: nextNameFa,
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
slug: nextSlug,
},
});
if (dto.categoryIds) {
await tx.businessCategoryAssignment.deleteMany({ where: { businessId } });
await tx.businessCategoryAssignment.createMany({
data: dto.categoryIds.map((id) => ({
businessId,
categoryId: BigInt(id),
})),
});
}
if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) {
await tx.businessUser.deleteMany({
where: { businessId, isOwner: true },
});
await this.assignOwner(tx, businessId, BigInt(dto.ownerUserId), actor.id);
}
});
return this.getOne(businessIdRaw, actor);
}
async addDomain(businessIdRaw: string, dto: AddDomainDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const host = dto.host.trim();
if (!host) {
throw new BadRequestException('host is required');
}
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
if (!business) {
throw new NotFoundException('Business not found');
}
const hasPrimary = await this.prisma.domain.findFirst({
where: { businessId, isPrimary: true },
select: { id: true },
});
const isPrimary = dto.isPrimary ?? !hasPrimary;
return this.prisma.domain.create({
data: {
businessId,
host,
isPrimary,
isVerified: false,
sslEnabled: false,
},
});
}
async updateDomain(
businessIdRaw: string,
domainIdRaw: string,
dto: UpdateDomainDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const domainId = BigInt(domainIdRaw);
const host = dto.host.trim();
if (!host) {
throw new BadRequestException('host is required');
}
const domain = await this.prisma.domain.findFirst({
where: { id: domainId, businessId },
});
if (!domain) {
throw new NotFoundException('Domain not found');
}
const existing = await this.prisma.domain.findUnique({ where: { host } });
if (existing && existing.id !== domainId) {
throw new ConflictException('Domain host is already taken');
}
return this.prisma.domain.update({
where: { id: domainId },
data: { host },
});
}
async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
if (!business) {
throw new NotFoundException('Business not found');
}
return this.prisma.business.update({
where: { id: businessId },
data: { isActive: dto.isActive },
});
}
async remove(businessIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
if (!business) {
throw new NotFoundException('Business not found');
}
await this.prisma.business.delete({ where: { id: businessId } });
return { message: 'Business removed' };
}
private async assertSlugAvailable(slug: string, excludeId?: bigint) {
const existing = await this.prisma.business.findUnique({ where: { slug } });
if (existing && existing.id !== excludeId) {
throw new ConflictException('Business slug is already taken');
}
}
private async validateCategoryIds(categoryIds: number[]) {
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
const count = await this.prisma.businessCategory.count({
where: { id: { in: ids }, isActive: true },
});
if (count !== ids.length) {
throw new BadRequestException('One or more categoryIds are invalid');
}
}
private async createOwnerUser(
tx: Prisma.TransactionClient,
dto: Pick<
CreateBusinessDto,
'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword'
>,
) {
const existing = await tx.user.findUnique({
where: { cellNumber: dto.ownerCellNumber },
});
if (existing) {
throw new ConflictException('A user with this cell number already exists');
}
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
return tx.user.create({
data: {
cellNumber: dto.ownerCellNumber,
passwordHash,
firstName: dto.ownerFirstName.trim(),
lastName: dto.ownerLastName.trim(),
cellVerifiedAt: new Date(),
},
});
}
private async findOwnerUser(ownerUserId: number) {
const owner = await this.prisma.user.findUnique({
where: { id: BigInt(ownerUserId) },
});
if (!owner || !owner.isActive) {
throw new NotFoundException('Owner user not found');
}
return owner;
}
private async assignOwner(
tx: Prisma.TransactionClient,
businessId: bigint,
ownerUserId: bigint,
invitedBy: bigint,
) {
const businessOwnerRole = await tx.role.findUnique({
where: { slug: 'business_owner' },
});
if (!businessOwnerRole) {
throw new Error('business_owner role is missing');
}
await tx.businessUser.upsert({
where: {
businessId_userId: { businessId, userId: ownerUserId },
},
create: {
businessId,
userId: ownerUserId,
isOwner: true,
invitedBy,
},
update: {
isOwner: true,
roleId: null,
invitedBy,
},
});
const hasRole = await tx.userRole.findUnique({
where: {
userId_roleId: {
userId: ownerUserId,
roleId: businessOwnerRole.id,
},
},
});
if (!hasRole) {
await tx.userRole.create({
data: { userId: ownerUserId, roleId: businessOwnerRole.id },
});
}
}
private serializeBusiness(
business: {
id: bigint;
name: string;
nameFa: string | null;
about: string | null;
slug: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
categoryAssignments: {
category: {
id: bigint;
name: string;
slug: string;
parentId: bigint | null;
};
}[];
businessUsers: {
user: {
id: bigint;
cellNumber: string;
firstName: string | null;
lastName: string | null;
email: string | null;
};
}[];
domains: {
id: bigint;
host: string;
isPrimary: boolean;
isVerified: boolean;
sslEnabled: boolean;
}[];
},
) {
const owner = business.businessUsers[0]?.user ?? null;
return {
id: business.id,
name: business.name,
nameFa: business.nameFa,
about: business.about,
slug: business.slug,
isActive: business.isActive,
createdAt: business.createdAt,
updatedAt: business.updatedAt,
categories: business.categoryAssignments.map((a) => ({
id: a.category.id,
name: a.category.name,
slug: a.category.slug,
parentId: a.category.parentId,
})),
categoryIds: business.categoryAssignments.map((a) => a.category.id),
owner: owner
? {
id: owner.id,
cellNumber: owner.cellNumber,
firstName: owner.firstName,
lastName: owner.lastName,
email: owner.email,
}
: null,
ownerUserId: owner?.id ?? null,
domains: business.domains,
};
}
private formatBusinessLabel(business: {
name: string;
nameFa: string | null;
slug: string;
}): string {
if (business.nameFa && business.nameFa !== business.name) {
return `${business.name} / ${business.nameFa}`;
}
return business.name;
}
}
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types';
import { BusinessCategoriesService } from './business-categories.service';
@Controller('business-categories')
export class BusinessCategoriesController {
constructor(private readonly service: BusinessCategoriesService) {}
@Get()
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: AuthUser) {
return this.service.list(user);
}
}
@@ -0,0 +1,40 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionsService } from '../auth/permissions.service';
import { AuthUser } from '../auth/auth.types';
@Injectable()
export class BusinessCategoriesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(actor: AuthUser) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
const canRead =
isSuperAdmin ||
actor.roles.includes('business_owner') ||
actor.roles.includes('business_staff');
if (!canRead) {
throw new ForbiddenException('Access denied');
}
const categories = await this.prisma.businessCategory.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
select: {
id: true,
parentId: true,
name: true,
slug: true,
description: true,
icon: true,
sortOrder: true,
},
});
return { items: categories };
}
}

Some files were not shown because too many files have changed in this diff Show More