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