mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
Generated
+8
@@ -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
|
||||
Generated
+12
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Executable
+27
@@ -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."
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
Executable
+27
@@ -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."
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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)
|
||||
);
|
||||
Executable
+9
@@ -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"
|
||||
Executable
+29
@@ -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
|
||||
@@ -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: {}
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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` |
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+6487
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 : '',
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user