mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Ship legacy migrate APIs, portfolio/blog old-id schema, and admin migrate/purge flows.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7244b70e90
commit
4598add88c
@@ -9,7 +9,7 @@ Read `docs/PROJECT_CONTEXT.md` for full reference before large changes.
|
|||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
NestJS 11 + TypeScript + Prisma 6 + PostgreSQL 16 + Redis + S3 (Parmin).
|
NestJS 11 + TypeScript + Prisma 6 + PostgreSQL 16 + Redis + S3 (Parspack).
|
||||||
|
|
||||||
API prefix: `/api/v1`. Package name: `meshkee-cms-api`.
|
API prefix: `/api/v1`. Package name: `meshkee-cms-api`.
|
||||||
|
|
||||||
|
|||||||
+23
-4
@@ -24,16 +24,35 @@ JWT_REFRESH_EXPIRES_IN=7d
|
|||||||
# SMS (set to true when SMS provider API is ready)
|
# SMS (set to true when SMS provider API is ready)
|
||||||
SMS_ENABLED=false
|
SMS_ENABLED=false
|
||||||
|
|
||||||
# Object storage (Parmin / S3-compatible)
|
# Object storage (Parspack / S3-compatible)
|
||||||
|
# Bucket id is the Parspack account id. Object keys live under meshkee/...
|
||||||
|
# meshkee/businesses/{businessId}/media/{file}
|
||||||
|
# meshkee/businesses/{businessId}/brand/{file}
|
||||||
STORAGE_DISK=s3
|
STORAGE_DISK=s3
|
||||||
S3_ENDPOINT=https://sas.amin.parminstorage.ir
|
S3_ENDPOINT=https://c804387.parspack.net
|
||||||
S3_BUCKET=meshkee-storage
|
S3_BUCKET=c804387
|
||||||
S3_PUBLIC_URL=https://meshkee-storage.sas.amin.parminstorage.ir
|
S3_PUBLIC_URL=https://c804387.parspack.net/c804387
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_FORCE_PATH_STYLE=true
|
S3_FORCE_PATH_STYLE=true
|
||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
# Legacy WillaEngine MySQL (SSH tunnel to old host, e.g. ssh -L 3307:127.0.0.1:3307 root@89.42.138.52)
|
||||||
|
OLD_MYSQL_HOST=127.0.0.1
|
||||||
|
OLD_MYSQL_PORT=3307
|
||||||
|
OLD_MYSQL_USER=
|
||||||
|
OLD_MYSQL_PASSWORD=
|
||||||
|
OLD_MYSQL_DATABASE=willaengine
|
||||||
|
|
||||||
|
# Legacy Parmin S3 (source for portfolio/media byte-copy; images >300KB are skipped)
|
||||||
|
OLD_S3_ENDPOINT=https://sas.amin.parminstorage.ir
|
||||||
|
OLD_S3_BUCKET=meshkee-storage
|
||||||
|
OLD_S3_PUBLIC_URL=https://meshkee-storage.sas.amin.parminstorage.ir
|
||||||
|
OLD_S3_REGION=us-east-1
|
||||||
|
OLD_S3_FORCE_PATH_STYLE=true
|
||||||
|
OLD_S3_ACCESS_KEY_ID=
|
||||||
|
OLD_S3_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
# AI product generation (use Groq free tier or OpenAI)
|
# AI product generation (use Groq free tier or OpenAI)
|
||||||
AI_PROVIDER=groq
|
AI_PROVIDER=groq
|
||||||
GROQ_API_KEY=
|
GROQ_API_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Link Meshkee businesses to legacy WillaEngine business ids for selective migration
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS old_business_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS businesses_old_business_id_unique
|
||||||
|
ON businesses (old_business_id)
|
||||||
|
WHERE old_business_id IS NOT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Link content categories to legacy WillaEngine category ids (per entity type)
|
||||||
|
ALTER TABLE categories
|
||||||
|
ADD COLUMN IF NOT EXISTS old_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS categories_business_entity_old_id_unique
|
||||||
|
ON categories (business_id, entity_type, old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Link migrated portfolios / media to legacy WillaEngine ids
|
||||||
|
ALTER TABLE portfolios
|
||||||
|
ADD COLUMN IF NOT EXISTS old_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS portfolios_business_old_id_unique
|
||||||
|
ON portfolios (business_id, old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE media
|
||||||
|
ADD COLUMN IF NOT EXISTS old_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS media_business_old_id_unique
|
||||||
|
ON media (business_id, old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Bilingual portfolio titles (legacy name / name_en)
|
||||||
|
ALTER TABLE portfolios
|
||||||
|
ADD COLUMN IF NOT EXISTS title_fa VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE portfolios
|
||||||
|
ADD COLUMN IF NOT EXISTS title_en VARCHAR(255);
|
||||||
|
|
||||||
|
-- Existing rows: FA from title; EN from content.nameEn when present
|
||||||
|
UPDATE portfolios
|
||||||
|
SET title_fa = title
|
||||||
|
WHERE title_fa IS NULL;
|
||||||
|
|
||||||
|
UPDATE portfolios
|
||||||
|
SET title_en = NULLIF(BTRIM(content->>'nameEn'), '')
|
||||||
|
WHERE title_en IS NULL
|
||||||
|
AND content ? 'nameEn';
|
||||||
|
|
||||||
|
-- Keep title as the primary FA display title
|
||||||
|
UPDATE portfolios
|
||||||
|
SET title = COALESCE(NULLIF(BTRIM(title_fa), ''), title)
|
||||||
|
WHERE title_fa IS NOT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Link migrated blogs to legacy WillaEngine article ids
|
||||||
|
ALTER TABLE blogs
|
||||||
|
ADD COLUMN IF NOT EXISTS old_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS blogs_business_old_id_unique
|
||||||
|
ON blogs (business_id, old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Customer categories (entity_type) + user legacy ids + blogs old_id unique per post_type
|
||||||
|
|
||||||
|
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'customer';
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS old_id BIGINT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_old_id_unique
|
||||||
|
ON users (old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Articles and news can share numeric ids; scope uniqueness by post_type
|
||||||
|
DROP INDEX IF EXISTS blogs_business_old_id_unique;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS blogs_business_post_type_old_id_unique
|
||||||
|
ON blogs (business_id, post_type, old_id)
|
||||||
|
WHERE old_id IS NOT NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Portfolio authors (same pattern as blogs.author_id)
|
||||||
|
ALTER TABLE portfolios
|
||||||
|
ADD COLUMN IF NOT EXISTS author_id BIGINT;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'portfolios_author_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE portfolios
|
||||||
|
ADD CONSTRAINT portfolios_author_id_fkey
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users (id)
|
||||||
|
ON UPDATE NO ACTION
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portfolios_author_id ON portfolios (author_id);
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| Cache | Redis 7 (OTP storage) |
|
| Cache | Redis 7 (OTP storage) |
|
||||||
| Auth | JWT (access + refresh), Passport, bcrypt |
|
| Auth | JWT (access + refresh), Passport, bcrypt |
|
||||||
| Validation | class-validator + class-transformer |
|
| Validation | class-validator + class-transformer |
|
||||||
| File storage | S3-compatible (Parmin), Sharp for image processing |
|
| File storage | S3-compatible (Parspack), Sharp for image processing |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -473,7 +473,8 @@ Field keys are auto-slugified from labels (e.g. `"Weight"` → `"weight"`).
|
|||||||
|
|
||||||
### Media
|
### Media
|
||||||
|
|
||||||
- Multipart upload to S3 via Sharp processing
|
- Multipart upload to Parspack S3 via Sharp processing
|
||||||
|
- Object keys: `meshkee/businesses/{businessId}/media/{uuid}{ext}` (library), `meshkee/businesses/{businessId}/brand/favicon-*.png` (derived favicons)
|
||||||
- Polymorphic attachments to products (and future blog/portfolio entities)
|
- Polymorphic attachments to products (and future blog/portfolio entities)
|
||||||
- Featured image on products via `featuredMediaId`
|
- Featured image on products via `featuredMediaId`
|
||||||
|
|
||||||
@@ -530,6 +531,8 @@ See `.env.example` for the full list. Key groups:
|
|||||||
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
|
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
|
||||||
| SMS | `SMS_ENABLED` |
|
| SMS | `SMS_ENABLED` |
|
||||||
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
||||||
|
| Legacy MySQL (WillaEngine migrate) | `OLD_MYSQL_HOST`, `OLD_MYSQL_PORT`, `OLD_MYSQL_USER`, `OLD_MYSQL_PASSWORD`, `OLD_MYSQL_DATABASE` |
|
||||||
|
| Legacy S3 source (media copy) | `OLD_S3_ENDPOINT`, `OLD_S3_BUCKET`, `OLD_S3_PUBLIC_URL`, `OLD_S3_ACCESS_KEY_ID`, `OLD_S3_SECRET_ACCESS_KEY` |
|
||||||
| Media | `MEDIA_MAX_FILE_SIZE_MB` |
|
| Media | `MEDIA_MAX_FILE_SIZE_MB` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -540,6 +543,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
|
|
||||||
- Multi-tenant auth (register, login, OTP, profile)
|
- Multi-tenant auth (register, login, OTP, profile)
|
||||||
- Super admin: users, businesses, domains, system business categories
|
- Super admin: users, businesses, domains, system business categories
|
||||||
|
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
|
||||||
- Business team management
|
- Business team management
|
||||||
- Media upload (S3 + Sharp)
|
- Media upload (S3 + Sharp)
|
||||||
- Categories (all entity types in DB; API supports `entityType` filter)
|
- Categories (all entity types in DB; API supports `entityType` filter)
|
||||||
@@ -557,7 +561,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
| Feature | DB | Permissions | API | Prisma |
|
| Feature | DB | Permissions | API | Prisma |
|
||||||
|---------|----|-------------|-----|--------|
|
|---------|----|-------------|-----|--------|
|
||||||
| Blogs | Yes | Yes | No | No model |
|
| Blogs | Yes | Yes | No | No model |
|
||||||
| Portfolios | Yes | Yes | No | No model |
|
| Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes |
|
||||||
| Customer dashboard | Partial | No | Register only | Yes |
|
| Customer dashboard | Partial | No | Register only | Yes |
|
||||||
| SMS provider | — | — | Stub | — |
|
| SMS provider | — | — | Stub | — |
|
||||||
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
|
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
|
||||||
|
|||||||
Generated
+95
@@ -20,6 +20,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"mysql2": "^3.23.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -2424,6 +2425,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/aws-ssl-profiles": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -3760,6 +3770,15 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/generate-function": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-property": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -4104,6 +4123,12 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-property": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-unicode-supported": {
|
"node_modules/is-unicode-supported": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
@@ -4398,6 +4423,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.5.2",
|
"version": "11.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
@@ -4408,6 +4439,21 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru.min": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=1.30.0",
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
@@ -4681,6 +4727,40 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mysql2": {
|
||||||
|
"version": "3.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz",
|
||||||
|
"integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"generate-function": "^2.3.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
|
"lru.min": "^1.1.4",
|
||||||
|
"named-placeholders": "^1.1.6",
|
||||||
|
"sql-escaper": "^1.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/named-placeholders": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lru.min": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
@@ -5680,6 +5760,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sql-escaper": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=2.0.0",
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/standard-as-callback": {
|
"node_modules/standard-as-callback": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"mysql2": "^3.23.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
+208
-200
@@ -20,8 +20,8 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
profile Json @default("{}")
|
profile Json @default("{}")
|
||||||
|
oldId BigInt? @map("old_id")
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
blogs blogs[]
|
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
|
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
|
||||||
businessUsers BusinessUser[] @relation("BusinessMember")
|
businessUsers BusinessUser[] @relation("BusinessMember")
|
||||||
@@ -29,6 +29,7 @@ model User {
|
|||||||
commentsApproved Comment[] @relation("CommentApprover")
|
commentsApproved Comment[] @relation("CommentApprover")
|
||||||
expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover")
|
expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover")
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
|
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
||||||
mediaUploaded Media[]
|
mediaUploaded Media[]
|
||||||
ordersCreated Order[] @relation("OrderCreator")
|
ordersCreated Order[] @relation("OrderCreator")
|
||||||
orders Order[] @relation("OrderCustomer")
|
orders Order[] @relation("OrderCustomer")
|
||||||
@@ -36,8 +37,9 @@ model User {
|
|||||||
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
||||||
transactionsCreated Transaction[] @relation("TransactionCreator")
|
transactionsCreated Transaction[] @relation("TransactionCreator")
|
||||||
transactions Transaction[] @relation("TransactionCustomer")
|
transactions Transaction[] @relation("TransactionCustomer")
|
||||||
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
|
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||||
|
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
||||||
|
|
||||||
@@index([cellNumber], map: "idx_users_cell_number")
|
@@index([cellNumber], map: "idx_users_cell_number")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -60,14 +62,15 @@ model Business {
|
|||||||
socialMedia Json @default("{}") @map("social_media")
|
socialMedia Json @default("{}") @map("social_media")
|
||||||
logoMediaId BigInt? @map("logo_media_id")
|
logoMediaId BigInt? @map("logo_media_id")
|
||||||
faviconMediaId BigInt? @map("favicon_media_id")
|
faviconMediaId BigInt? @map("favicon_media_id")
|
||||||
|
oldBusinessId BigInt? @map("old_business_id")
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brands Brand[]
|
brands Brand[]
|
||||||
categoryAssignments BusinessCategoryAssignment[]
|
categoryAssignments BusinessCategoryAssignment[]
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
businessUsers BusinessUser[]
|
businessUsers BusinessUser[]
|
||||||
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
|
|
||||||
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
|
||||||
carts Cart[]
|
carts Cart[]
|
||||||
categories Category[]
|
categories Category[]
|
||||||
contentCategoryAssignments CategoryAssignment[]
|
contentCategoryAssignments CategoryAssignment[]
|
||||||
@@ -78,6 +81,10 @@ model Business {
|
|||||||
domains Domain[]
|
domains Domain[]
|
||||||
expertReviews ExpertReview[]
|
expertReviews ExpertReview[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
|
invoiceItemTemplates InvoiceItemTemplate[]
|
||||||
|
invoiceTemplates InvoiceTemplate[]
|
||||||
|
invoices Invoice[] @relation("InvoiceBusiness")
|
||||||
|
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||||
media Media[]
|
media Media[]
|
||||||
mediaAttachments MediaAttachment[]
|
mediaAttachments MediaAttachment[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
@@ -89,10 +96,6 @@ model Business {
|
|||||||
storeItems StoreItem[]
|
storeItems StoreItem[]
|
||||||
storeSpecials StoreSpecial[]
|
storeSpecials StoreSpecial[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
invoices Invoice[] @relation("InvoiceBusiness")
|
|
||||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
|
||||||
invoiceItemTemplates InvoiceItemTemplate[]
|
|
||||||
invoiceTemplates InvoiceTemplate[]
|
|
||||||
website_brand_groups website_brand_groups[]
|
website_brand_groups website_brand_groups[]
|
||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
@@ -133,20 +136,20 @@ model BusinessCategoryAssignment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Domain {
|
model Domain {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
host String @unique(map: "domains_host_unique") @db.VarChar(253)
|
host String @unique(map: "domains_host_unique") @db.VarChar(253)
|
||||||
isPrimary Boolean @default(false) @map("is_primary")
|
isPrimary Boolean @default(false) @map("is_primary")
|
||||||
isVerified Boolean @default(false) @map("is_verified")
|
isVerified Boolean @default(false) @map("is_verified")
|
||||||
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
||||||
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
|
lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
|
||||||
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
@@index([businessId], map: "idx_domains_business_id")
|
@@index([businessId], map: "idx_domains_business_id")
|
||||||
@@index([host], map: "idx_domains_host")
|
@@index([host], map: "idx_domains_host")
|
||||||
@@ -263,10 +266,11 @@ model Media {
|
|||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
oldId BigInt? @map("old_id")
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brandImages Brand[]
|
brandImages Brand[]
|
||||||
logoBusinesses Business[] @relation("BusinessLogo")
|
|
||||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||||
|
logoBusinesses Business[] @relation("BusinessLogo")
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
||||||
attachments MediaAttachment[]
|
attachments MediaAttachment[]
|
||||||
@@ -298,7 +302,7 @@ model MediaAttachment {
|
|||||||
@@map("media_attachments")
|
@@map("media_attachments")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
entityType MediaEntityType @map("entity_type")
|
entityType MediaEntityType @map("entity_type")
|
||||||
@@ -311,6 +315,7 @@ model Category {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
nameFa String? @map("name_fa") @db.VarChar(255)
|
nameFa String? @map("name_fa") @db.VarChar(255)
|
||||||
|
oldId BigInt? @map("old_id")
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
@@ -610,7 +615,8 @@ model blogs {
|
|||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
post_type BlogPostType @default(blog)
|
post_type BlogPostType @default(blog)
|
||||||
users User? @relation(fields: [author_id], references: [id], onUpdate: NoAction)
|
old_id BigInt?
|
||||||
|
author User? @relation("BlogAuthor", fields: [author_id], references: [id], onUpdate: NoAction)
|
||||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
@@ -624,7 +630,10 @@ model blogs {
|
|||||||
model portfolios {
|
model portfolios {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
business_id BigInt
|
business_id BigInt
|
||||||
|
author_id BigInt?
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
|
title_fa String? @db.VarChar(255)
|
||||||
|
title_en String? @db.VarChar(255)
|
||||||
slug String @db.VarChar(255)
|
slug String @db.VarChar(255)
|
||||||
description String?
|
description String?
|
||||||
content Json @default("{}")
|
content Json @default("{}")
|
||||||
@@ -637,6 +646,8 @@ model portfolios {
|
|||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
old_id BigInt?
|
||||||
|
author User? @relation("PortfolioAuthor", fields: [author_id], references: [id], onUpdate: NoAction, onDelete: SetNull)
|
||||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
@@ -644,6 +655,7 @@ model portfolios {
|
|||||||
@@index([business_id], map: "idx_portfolios_business_id")
|
@@index([business_id], map: "idx_portfolios_business_id")
|
||||||
@@index([business_id, published_at(sort: Desc)], map: "idx_portfolios_business_published")
|
@@index([business_id, published_at(sort: Desc)], map: "idx_portfolios_business_published")
|
||||||
@@index([business_id, status], map: "idx_portfolios_business_status")
|
@@index([business_id, status], map: "idx_portfolios_business_status")
|
||||||
|
@@index([author_id], map: "idx_portfolios_author_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
@@ -651,7 +663,6 @@ model Address {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId BigInt? @map("user_id")
|
userId BigInt? @map("user_id")
|
||||||
businessId BigInt? @map("business_id")
|
businessId BigInt? @map("business_id")
|
||||||
label String? @db.VarChar(100)
|
|
||||||
province String @db.VarChar(100)
|
province String @db.VarChar(100)
|
||||||
city String @db.VarChar(100)
|
city String @db.VarChar(100)
|
||||||
address String
|
address String
|
||||||
@@ -659,6 +670,7 @@ model Address {
|
|||||||
landline String? @db.VarChar(30)
|
landline String? @db.VarChar(30)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
label String? @db.VarChar(100)
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
orders Order[]
|
orders Order[]
|
||||||
@@ -1039,6 +1051,175 @@ model website_sliders {
|
|||||||
@@index([business_id], map: "idx_website_sliders_business_id")
|
@@index([business_id], map: "idx_website_sliders_business_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoiceItemTemplate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
|
businessId BigInt? @map("business_id")
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
duration String? @db.VarChar(100)
|
||||||
|
worktime String? @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
price Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
invoiceItems InvoiceItem[]
|
||||||
|
invoiceTemplateItems InvoiceTemplateItem[]
|
||||||
|
|
||||||
|
@@index([ownerScope, sortOrder], map: "idx_invoice_item_templates_owner_scope")
|
||||||
|
@@map("invoice_item_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceTemplate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
|
businessId BigInt? @map("business_id")
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
topText String? @map("top_text")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
accounts InvoiceTemplateAccount[]
|
||||||
|
items InvoiceTemplateItem[]
|
||||||
|
keyPoints InvoiceTemplateKeyPoint[]
|
||||||
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
invoices Invoice[]
|
||||||
|
|
||||||
|
@@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope")
|
||||||
|
@@map("invoice_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceTemplateItem {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
templateId BigInt @map("template_id")
|
||||||
|
itemTemplateId BigInt? @map("item_template_id")
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
duration String? @db.VarChar(100)
|
||||||
|
worktime String? @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
price Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
itemTemplate InvoiceItemTemplate? @relation(fields: [itemTemplateId], references: [id], onUpdate: NoAction)
|
||||||
|
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([templateId, sortOrder], map: "idx_invoice_template_items_template_id")
|
||||||
|
@@map("invoice_template_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceTemplateKeyPoint {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
templateId BigInt @map("template_id")
|
||||||
|
text String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([templateId, sortOrder], map: "idx_invoice_template_key_points_template_id")
|
||||||
|
@@map("invoice_template_key_points")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceTemplateAccount {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
templateId BigInt @map("template_id")
|
||||||
|
bankName String @map("bank_name") @db.VarChar(255)
|
||||||
|
cardNumber String? @map("card_number") @db.VarChar(64)
|
||||||
|
iban String? @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
|
||||||
|
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([templateId, sortOrder], map: "idx_invoice_template_accounts_template_id")
|
||||||
|
@@map("invoice_template_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invoice {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope")
|
||||||
|
issuerBusinessId BigInt? @map("issuer_business_id")
|
||||||
|
status InvoiceStatus @default(issued)
|
||||||
|
notes String?
|
||||||
|
issuedBy BigInt? @map("issued_by")
|
||||||
|
issuedAt DateTime @default(now()) @map("issued_at") @db.Timestamptz(6)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
name String? @db.VarChar(255)
|
||||||
|
topText String? @map("top_text")
|
||||||
|
invoiceTemplateId BigInt? @map("invoice_template_id")
|
||||||
|
publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32)
|
||||||
|
accounts InvoiceAccount[]
|
||||||
|
items InvoiceItem[]
|
||||||
|
keyPoints InvoiceKeyPoint[]
|
||||||
|
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
||||||
|
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||||
|
issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created")
|
||||||
|
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
||||||
|
@@index([status], map: "idx_invoices_status")
|
||||||
|
@@map("invoices")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceItem {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
invoiceId BigInt @map("invoice_id")
|
||||||
|
templateId BigInt? @map("template_id")
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
duration String? @db.VarChar(100)
|
||||||
|
worktime String? @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
price Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
template InvoiceItemTemplate? @relation(fields: [templateId], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([invoiceId, sortOrder], map: "idx_invoice_items_invoice_id")
|
||||||
|
@@map("invoice_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceKeyPoint {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
invoiceId BigInt @map("invoice_id")
|
||||||
|
text String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([invoiceId, sortOrder], map: "idx_invoice_key_points_invoice_id")
|
||||||
|
@@map("invoice_key_points")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceAccount {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
invoiceId BigInt @map("invoice_id")
|
||||||
|
bankName String @map("bank_name") @db.VarChar(255)
|
||||||
|
cardNumber String? @map("card_number") @db.VarChar(64)
|
||||||
|
iban String? @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([invoiceId, sortOrder], map: "idx_invoice_accounts_invoice_id")
|
||||||
|
@@map("invoice_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
enum MediaType {
|
enum MediaType {
|
||||||
image
|
image
|
||||||
video
|
video
|
||||||
@@ -1050,6 +1231,7 @@ enum MediaEntityType {
|
|||||||
product
|
product
|
||||||
blog
|
blog
|
||||||
portfolio
|
portfolio
|
||||||
|
customer
|
||||||
|
|
||||||
@@map("media_entity_type")
|
@@map("media_entity_type")
|
||||||
}
|
}
|
||||||
@@ -1141,183 +1323,9 @@ enum InvoiceOwnerScope {
|
|||||||
enum InvoiceStatus {
|
enum InvoiceStatus {
|
||||||
draft
|
draft
|
||||||
issued
|
issued
|
||||||
approved
|
|
||||||
paid
|
paid
|
||||||
cancelled
|
cancelled
|
||||||
|
approved
|
||||||
|
|
||||||
@@map("invoice_status")
|
@@map("invoice_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
model InvoiceItemTemplate {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
|
||||||
businessId BigInt? @map("business_id")
|
|
||||||
title String @db.VarChar(255)
|
|
||||||
duration String? @db.VarChar(100)
|
|
||||||
worktime String? @db.VarChar(100)
|
|
||||||
description String?
|
|
||||||
price Decimal @default(0) @db.Decimal(12, 2)
|
|
||||||
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
isActive Boolean @default(true) @map("is_active")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
invoiceItems InvoiceItem[]
|
|
||||||
invoiceTemplateItems InvoiceTemplateItem[]
|
|
||||||
|
|
||||||
@@index([ownerScope, sortOrder], map: "idx_invoice_item_templates_owner_scope")
|
|
||||||
@@index([businessId, sortOrder], map: "idx_invoice_item_templates_business_id")
|
|
||||||
@@map("invoice_item_templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceTemplate {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
|
||||||
businessId BigInt? @map("business_id")
|
|
||||||
name String @db.VarChar(255)
|
|
||||||
topText String? @map("top_text")
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
isActive Boolean @default(true) @map("is_active")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
items InvoiceTemplateItem[]
|
|
||||||
keyPoints InvoiceTemplateKeyPoint[]
|
|
||||||
accounts InvoiceTemplateAccount[]
|
|
||||||
invoices Invoice[]
|
|
||||||
|
|
||||||
@@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope")
|
|
||||||
@@index([businessId, sortOrder], map: "idx_invoice_templates_business_id")
|
|
||||||
@@map("invoice_templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceTemplateItem {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
templateId BigInt @map("template_id")
|
|
||||||
itemTemplateId BigInt? @map("item_template_id")
|
|
||||||
title String @db.VarChar(255)
|
|
||||||
duration String? @db.VarChar(100)
|
|
||||||
worktime String? @db.VarChar(100)
|
|
||||||
description String?
|
|
||||||
price Decimal @default(0) @db.Decimal(12, 2)
|
|
||||||
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
itemTemplate InvoiceItemTemplate? @relation(fields: [itemTemplateId], references: [id], onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([templateId, sortOrder], map: "idx_invoice_template_items_template_id")
|
|
||||||
@@map("invoice_template_items")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceTemplateKeyPoint {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
templateId BigInt @map("template_id")
|
|
||||||
text String
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([templateId, sortOrder], map: "idx_invoice_template_key_points_template_id")
|
|
||||||
@@map("invoice_template_key_points")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceTemplateAccount {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
templateId BigInt @map("template_id")
|
|
||||||
bankName String @map("bank_name") @db.VarChar(255)
|
|
||||||
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
|
|
||||||
cardNumber String? @map("card_number") @db.VarChar(64)
|
|
||||||
iban String? @db.VarChar(64)
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([templateId, sortOrder], map: "idx_invoice_template_accounts_template_id")
|
|
||||||
@@map("invoice_template_accounts")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Invoice {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32)
|
|
||||||
businessId BigInt @map("business_id")
|
|
||||||
ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope")
|
|
||||||
issuerBusinessId BigInt? @map("issuer_business_id")
|
|
||||||
status InvoiceStatus @default(issued)
|
|
||||||
name String? @db.VarChar(255)
|
|
||||||
topText String? @map("top_text")
|
|
||||||
notes String?
|
|
||||||
invoiceTemplateId BigInt? @map("invoice_template_id")
|
|
||||||
issuedBy BigInt? @map("issued_by")
|
|
||||||
issuedAt DateTime @default(now()) @map("issued_at") @db.Timestamptz(6)
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], references: [id], onUpdate: NoAction)
|
|
||||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
|
||||||
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
|
||||||
items InvoiceItem[]
|
|
||||||
keyPoints InvoiceKeyPoint[]
|
|
||||||
accounts InvoiceAccount[]
|
|
||||||
|
|
||||||
@@index([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created")
|
|
||||||
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
|
||||||
@@index([issuerBusinessId, createdAt(sort: Desc)], map: "idx_invoices_issuer_business_id")
|
|
||||||
@@index([status], map: "idx_invoices_status")
|
|
||||||
@@index([invoiceTemplateId], map: "idx_invoices_invoice_template_id")
|
|
||||||
@@map("invoices")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceItem {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
invoiceId BigInt @map("invoice_id")
|
|
||||||
templateId BigInt? @map("template_id")
|
|
||||||
title String @db.VarChar(255)
|
|
||||||
duration String? @db.VarChar(100)
|
|
||||||
worktime String? @db.VarChar(100)
|
|
||||||
description String?
|
|
||||||
price Decimal @default(0) @db.Decimal(12, 2)
|
|
||||||
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
template InvoiceItemTemplate? @relation(fields: [templateId], references: [id], onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([invoiceId, sortOrder], map: "idx_invoice_items_invoice_id")
|
|
||||||
@@index([templateId], map: "idx_invoice_items_template_id")
|
|
||||||
@@map("invoice_items")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceKeyPoint {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
invoiceId BigInt @map("invoice_id")
|
|
||||||
text String
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([invoiceId, sortOrder], map: "idx_invoice_key_points_invoice_id")
|
|
||||||
@@map("invoice_key_points")
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvoiceAccount {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
invoiceId BigInt @map("invoice_id")
|
|
||||||
bankName String @map("bank_name") @db.VarChar(255)
|
|
||||||
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
|
|
||||||
cardNumber String? @map("card_number") @db.VarChar(64)
|
|
||||||
iban String? @db.VarChar(64)
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([invoiceId, sortOrder], map: "idx_invoice_accounts_invoice_id")
|
|
||||||
@@map("invoice_accounts")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ import { WebsiteModule } from './website/website.module';
|
|||||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||||
import { InvoicesModule } from './invoices/invoices.module';
|
import { InvoicesModule } from './invoices/invoices.module';
|
||||||
|
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
LegacyMysqlModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BusinessTeamModule,
|
BusinessTeamModule,
|
||||||
BusinessAdminModule,
|
BusinessAdminModule,
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ function slugify(value: string): string {
|
|||||||
type BlogWithRelations = Prisma.blogsGetPayload<{
|
type BlogWithRelations = Prisma.blogsGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
media: true;
|
media: true;
|
||||||
users: { select: { id: true; firstName: true; lastName: true; email: true } };
|
author: { select: { id: true; firstName: true; lastName: true; email: true } };
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const blogInclude = {
|
const blogInclude = {
|
||||||
media: true,
|
media: true,
|
||||||
users: {
|
author: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
@@ -562,12 +562,12 @@ export class BlogsService {
|
|||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
authorId: blog.author_id?.toString() ?? null,
|
authorId: blog.author_id?.toString() ?? null,
|
||||||
author: blog.users
|
author: blog.author
|
||||||
? {
|
? {
|
||||||
id: blog.users.id.toString(),
|
id: blog.author.id.toString(),
|
||||||
firstName: blog.users.firstName,
|
firstName: blog.author.firstName,
|
||||||
lastName: blog.users.lastName,
|
lastName: blog.author.lastName,
|
||||||
email: blog.users.email,
|
email: blog.author.email,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
titleImageUrl: blog.media?.publicUrl ?? null,
|
titleImageUrl: blog.media?.publicUrl ?? null,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { AddDomainDto } from './dto/add-domain.dto';
|
|||||||
import { UpdateDomainDto } from './dto/update-domain.dto';
|
import { UpdateDomainDto } from './dto/update-domain.dto';
|
||||||
import { DisableBusinessDto } from './dto/disable-business.dto';
|
import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||||
|
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
||||||
|
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
||||||
import { BusinessAdminService } from './business-admin.service';
|
import { BusinessAdminService } from './business-admin.service';
|
||||||
|
|
||||||
@Controller('businesses')
|
@Controller('businesses')
|
||||||
@@ -55,6 +57,26 @@ export class BusinessAdminController {
|
|||||||
return this.service.update(businessId, dto, user);
|
return this.service.update(businessId, dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':businessId/migrate-from-old')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
migrateFromOld(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: MigrateFromOldDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.migrateFromOld(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':businessId/purge-data')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
purgeData(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: PurgeBusinessDataDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.purgeData(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':businessId/domains')
|
@Post(':businessId/domains')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
addDomain(
|
addDomain(
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { BusinessCategoriesController } from './business-categories.controller';
|
|||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
import { BusinessAdminController } from './business-admin.controller';
|
import { BusinessAdminController } from './business-admin.controller';
|
||||||
import { BusinessAdminService } from './business-admin.service';
|
import { BusinessAdminService } from './business-admin.service';
|
||||||
|
import { LegacyMigrateService } from './legacy-migrate.service';
|
||||||
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||||
providers: [BusinessAdminService, BusinessCategoriesService],
|
providers: [
|
||||||
|
BusinessAdminService,
|
||||||
|
BusinessCategoriesService,
|
||||||
|
LegacyMigrateService,
|
||||||
|
LegacyPurgeService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class BusinessAdminModule {}
|
export class BusinessAdminModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { DisableBusinessDto } from './dto/disable-business.dto';
|
|||||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||||
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
||||||
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
||||||
|
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
||||||
|
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
||||||
|
import { LegacyMigrateService } from './legacy-migrate.service';
|
||||||
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ type BusinessRow = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
oldBusinessId: bigint | null;
|
||||||
domainId: bigint | null;
|
domainId: bigint | null;
|
||||||
domain: string | null;
|
domain: string | null;
|
||||||
sslEnabled: boolean | null;
|
sslEnabled: boolean | null;
|
||||||
@@ -50,6 +55,8 @@ export class BusinessAdminService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly legacyMigrate: LegacyMigrateService,
|
||||||
|
private readonly legacyPurge: LegacyPurgeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertSuperAdmin(actor: AuthUser) {
|
private async assertSuperAdmin(actor: AuthUser) {
|
||||||
@@ -99,6 +106,7 @@ export class BusinessAdminService {
|
|||||||
b.slug AS "slug",
|
b.slug AS "slug",
|
||||||
b.created_at AS "createdAt",
|
b.created_at AS "createdAt",
|
||||||
b.is_active AS "isActive",
|
b.is_active AS "isActive",
|
||||||
|
b.old_business_id AS "oldBusinessId",
|
||||||
dom.id AS "domainId",
|
dom.id AS "domainId",
|
||||||
dom.host AS "domain",
|
dom.host AS "domain",
|
||||||
dom.ssl_enabled AS "sslEnabled",
|
dom.ssl_enabled AS "sslEnabled",
|
||||||
@@ -279,29 +287,46 @@ export class BusinessAdminService {
|
|||||||
await this.assertSlugAvailable(slug);
|
await this.assertSlugAvailable(slug);
|
||||||
await this.validateCategoryIds(dto.categoryIds);
|
await this.validateCategoryIds(dto.categoryIds);
|
||||||
|
|
||||||
const business = await this.prisma.$transaction(async (tx) => {
|
if (dto.oldBusinessId !== undefined) {
|
||||||
const owner = await this.createOwnerUser(tx, dto);
|
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId));
|
||||||
|
}
|
||||||
|
|
||||||
const created = await tx.business.create({
|
let business;
|
||||||
data: {
|
try {
|
||||||
name: dto.name.trim(),
|
business = await this.prisma.$transaction(async (tx) => {
|
||||||
nameFa: dto.nameFa.trim(),
|
const owner = await this.createOwnerUser(tx, dto);
|
||||||
about: dto.about?.trim() ?? null,
|
|
||||||
slug,
|
const created = await tx.business.create({
|
||||||
},
|
data: {
|
||||||
|
name: dto.name.trim(),
|
||||||
|
nameFa: dto.nameFa.trim(),
|
||||||
|
about: dto.about?.trim() ?? null,
|
||||||
|
slug,
|
||||||
|
oldBusinessId:
|
||||||
|
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
await tx.businessCategoryAssignment.createMany({
|
if (
|
||||||
data: dto.categoryIds.map((id) => ({
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
businessId: created.id,
|
err.code === 'P2002'
|
||||||
categoryId: BigInt(id),
|
) {
|
||||||
})),
|
throw new ConflictException('Business slug or old business id is already taken');
|
||||||
});
|
}
|
||||||
|
throw err;
|
||||||
await this.assignOwner(tx, created.id, owner.id, actor.id);
|
}
|
||||||
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.getOne(business.id.toString(), actor);
|
return this.getOne(business.id.toString(), actor);
|
||||||
}
|
}
|
||||||
@@ -335,6 +360,10 @@ export class BusinessAdminService {
|
|||||||
await this.findOwnerUser(dto.ownerUserId);
|
await this.findOwnerUser(dto.ownerUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.oldBusinessId !== undefined && dto.oldBusinessId !== null) {
|
||||||
|
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId);
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.business.update({
|
await tx.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
@@ -343,6 +372,12 @@ export class BusinessAdminService {
|
|||||||
nameFa: nextNameFa,
|
nameFa: nextNameFa,
|
||||||
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
|
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
|
||||||
slug: nextSlug,
|
slug: nextSlug,
|
||||||
|
...(dto.oldBusinessId !== undefined
|
||||||
|
? {
|
||||||
|
oldBusinessId:
|
||||||
|
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,6 +499,288 @@ export class BusinessAdminService {
|
|||||||
return { message: 'Business removed' };
|
return { message: 'Business removed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async migrateFromOld(businessIdRaw: string, dto: MigrateFromOldDto, actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const oldBusinessId = BigInt(dto.oldBusinessId);
|
||||||
|
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assertOldBusinessIdAvailable(oldBusinessId, businessId);
|
||||||
|
|
||||||
|
let updated;
|
||||||
|
try {
|
||||||
|
updated = await this.prisma.business.update({
|
||||||
|
where: { id: businessId },
|
||||||
|
data: { oldBusinessId },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Old business id ${dto.oldBusinessId} is already linked to another business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data copy for selected entities (portfolio categories implemented first).
|
||||||
|
const results = await this.legacyMigrate.migrateEntities(
|
||||||
|
businessId,
|
||||||
|
oldBusinessId,
|
||||||
|
dto.entities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const implemented = dto.entities.filter((entity) => {
|
||||||
|
const result = results[entity];
|
||||||
|
return result && !('status' in result && result.status === 'not_implemented');
|
||||||
|
});
|
||||||
|
const pending = dto.entities.filter((entity) => {
|
||||||
|
const result = results[entity];
|
||||||
|
return result && 'status' in result && result.status === 'not_implemented';
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const portfolioCats = results.portfolio_categories;
|
||||||
|
if (
|
||||||
|
portfolioCats &&
|
||||||
|
!('status' in portfolioCats) &&
|
||||||
|
'created' in portfolioCats
|
||||||
|
) {
|
||||||
|
parts.push(
|
||||||
|
`portfolio categories: ${portfolioCats.created} created, ${portfolioCats.skipped} skipped (${portfolioCats.total} total)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const portfolios = results.portfolio;
|
||||||
|
if (portfolios && !('status' in portfolios) && 'created' in portfolios) {
|
||||||
|
const imgBits = [
|
||||||
|
portfolios.imagesCopied != null
|
||||||
|
? `${portfolios.imagesCopied} images copied`
|
||||||
|
: null,
|
||||||
|
portfolios.imagesResized != null && portfolios.imagesResized > 0
|
||||||
|
? `${portfolios.imagesResized} resized (≤1280px)`
|
||||||
|
: null,
|
||||||
|
portfolios.titlesUpdated != null && portfolios.titlesUpdated > 0
|
||||||
|
? `${portfolios.titlesUpdated} titles updated`
|
||||||
|
: null,
|
||||||
|
portfolios.imagesFailed != null && portfolios.imagesFailed > 0
|
||||||
|
? `${portfolios.imagesFailed} images failed`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
parts.push(
|
||||||
|
`portfolios: ${portfolios.created} created, ${portfolios.skipped} skipped (${portfolios.total} total)` +
|
||||||
|
(imgBits.length ? `; ${imgBits.join(', ')}` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blogCats = results.blog_categories;
|
||||||
|
if (blogCats && !('status' in blogCats) && 'created' in blogCats) {
|
||||||
|
parts.push(
|
||||||
|
`blog categories: ${blogCats.created} created, ${blogCats.skipped} skipped (${blogCats.total} total)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blogs = results.blog;
|
||||||
|
if (blogs && !('status' in blogs) && 'created' in blogs) {
|
||||||
|
const imgBits = [
|
||||||
|
blogs.imagesCopied != null ? `${blogs.imagesCopied} images copied` : null,
|
||||||
|
blogs.imagesResized != null && blogs.imagesResized > 0
|
||||||
|
? `${blogs.imagesResized} resized (≤1280px)`
|
||||||
|
: null,
|
||||||
|
blogs.imagesFailed != null && blogs.imagesFailed > 0
|
||||||
|
? `${blogs.imagesFailed} images failed`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
parts.push(
|
||||||
|
`blogs: ${blogs.created} created, ${blogs.skipped} skipped (${blogs.total} total; articles + news)` +
|
||||||
|
(imgBits.length ? `; ${imgBits.join(', ')}` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const customerCats = results.customer_categories;
|
||||||
|
if (
|
||||||
|
customerCats &&
|
||||||
|
!('status' in customerCats) &&
|
||||||
|
'created' in customerCats
|
||||||
|
) {
|
||||||
|
parts.push(
|
||||||
|
`customer categories: ${customerCats.created} created, ${customerCats.skipped} skipped (${customerCats.total} total)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productCats = results.product_categories;
|
||||||
|
if (
|
||||||
|
productCats &&
|
||||||
|
!('status' in productCats) &&
|
||||||
|
'created' in productCats
|
||||||
|
) {
|
||||||
|
parts.push(
|
||||||
|
`product categories: ${productCats.created} created, ${productCats.skipped} skipped (${productCats.total} total)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customers = results.customer;
|
||||||
|
if (customers && !('status' in customers) && 'created' in customers) {
|
||||||
|
const skipBits = [
|
||||||
|
customers.skippedInvalidCell != null && customers.skippedInvalidCell > 0
|
||||||
|
? `${customers.skippedInvalidCell} invalid/missing cell`
|
||||||
|
: null,
|
||||||
|
customers.skippedAlreadyLinked != null &&
|
||||||
|
customers.skippedAlreadyLinked > 0
|
||||||
|
? `${customers.skippedAlreadyLinked} already linked`
|
||||||
|
: null,
|
||||||
|
customers.skippedCreateFailed != null &&
|
||||||
|
customers.skippedCreateFailed > 0
|
||||||
|
? `${customers.skippedCreateFailed} create failed`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
parts.push(
|
||||||
|
`customers: ${customers.created} created, ${customers.skipped} skipped (${customers.total} total)` +
|
||||||
|
(skipBits.length ? `; ${skipBits.join(', ')}` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = results.product;
|
||||||
|
if (products && !('status' in products) && 'created' in products) {
|
||||||
|
const productImageBits = [
|
||||||
|
products.imagesCopied != null
|
||||||
|
? `${products.imagesCopied} images copied`
|
||||||
|
: null,
|
||||||
|
products.imagesResized != null && products.imagesResized > 0
|
||||||
|
? `${products.imagesResized} resized`
|
||||||
|
: null,
|
||||||
|
products.imagesFailed != null && products.imagesFailed > 0
|
||||||
|
? `${products.imagesFailed} image failures`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
parts.push(
|
||||||
|
`products: ${products.created} created, ${products.skipped} skipped (${products.total} total)` +
|
||||||
|
(productImageBits.length ? `; ${productImageBits.join(', ')}` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pending.length) {
|
||||||
|
parts.push(`not implemented yet: ${pending.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status =
|
||||||
|
implemented.length === 0
|
||||||
|
? ('linked' as const)
|
||||||
|
: pending.length > 0
|
||||||
|
? ('partial' as const)
|
||||||
|
: ('ok' as const);
|
||||||
|
|
||||||
|
return {
|
||||||
|
businessId: updated.id.toString(),
|
||||||
|
oldBusinessId: updated.oldBusinessId?.toString() ?? String(dto.oldBusinessId),
|
||||||
|
entities: dto.entities,
|
||||||
|
status,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
parts.length > 0
|
||||||
|
? `Old business linked. ${parts.join('. ')}.`
|
||||||
|
: 'Old business id saved.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeData(businessIdRaw: string, dto: PurgeBusinessDataDto, 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 results = await this.legacyPurge.purgeEntities(businessId, dto.entities);
|
||||||
|
|
||||||
|
const implemented = dto.entities.filter((entity) => {
|
||||||
|
const result = results[entity];
|
||||||
|
return result && !('status' in result && result.status === 'not_implemented');
|
||||||
|
});
|
||||||
|
const pending = dto.entities.filter((entity) => {
|
||||||
|
const result = results[entity];
|
||||||
|
return result && 'status' in result && result.status === 'not_implemented';
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const productCats = results.product_categories;
|
||||||
|
if (productCats && !('status' in productCats) && 'deleted' in productCats) {
|
||||||
|
parts.push(`product categories: ${productCats.deleted} deleted`);
|
||||||
|
}
|
||||||
|
const products = results.product;
|
||||||
|
if (products && !('status' in products) && 'deleted' in products) {
|
||||||
|
const img =
|
||||||
|
products.imagesDeleted != null
|
||||||
|
? `, ${products.imagesDeleted} images deleted`
|
||||||
|
: '';
|
||||||
|
parts.push(`products: ${products.deleted} deleted${img}`);
|
||||||
|
}
|
||||||
|
const portfolioCats = results.portfolio_categories;
|
||||||
|
if (
|
||||||
|
portfolioCats &&
|
||||||
|
!('status' in portfolioCats) &&
|
||||||
|
'deleted' in portfolioCats
|
||||||
|
) {
|
||||||
|
parts.push(`portfolio categories: ${portfolioCats.deleted} deleted`);
|
||||||
|
}
|
||||||
|
const portfolios = results.portfolio;
|
||||||
|
if (portfolios && !('status' in portfolios) && 'deleted' in portfolios) {
|
||||||
|
const img =
|
||||||
|
portfolios.imagesDeleted != null
|
||||||
|
? `, ${portfolios.imagesDeleted} images deleted`
|
||||||
|
: '';
|
||||||
|
parts.push(`portfolios: ${portfolios.deleted} deleted${img}`);
|
||||||
|
}
|
||||||
|
const blogCats = results.blog_categories;
|
||||||
|
if (blogCats && !('status' in blogCats) && 'deleted' in blogCats) {
|
||||||
|
parts.push(`blog categories: ${blogCats.deleted} deleted`);
|
||||||
|
}
|
||||||
|
const blogs = results.blog;
|
||||||
|
if (blogs && !('status' in blogs) && 'deleted' in blogs) {
|
||||||
|
const img =
|
||||||
|
blogs.imagesDeleted != null
|
||||||
|
? `, ${blogs.imagesDeleted} images deleted`
|
||||||
|
: '';
|
||||||
|
parts.push(`blogs: ${blogs.deleted} deleted${img}`);
|
||||||
|
}
|
||||||
|
const customerCats = results.customer_categories;
|
||||||
|
if (customerCats && !('status' in customerCats) && 'deleted' in customerCats) {
|
||||||
|
parts.push(`customer categories: ${customerCats.deleted} deleted`);
|
||||||
|
}
|
||||||
|
const customers = results.customer;
|
||||||
|
if (customers && !('status' in customers) && 'deleted' in customers) {
|
||||||
|
parts.push(`customers: ${customers.deleted} deleted`);
|
||||||
|
}
|
||||||
|
if (pending.length) {
|
||||||
|
parts.push(`not implemented yet: ${pending.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status =
|
||||||
|
implemented.length === 0
|
||||||
|
? ('noop' as const)
|
||||||
|
: pending.length > 0
|
||||||
|
? ('partial' as const)
|
||||||
|
: ('ok' as const);
|
||||||
|
|
||||||
|
return {
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
entities: dto.entities,
|
||||||
|
status,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
parts.length > 0
|
||||||
|
? `Data removed. ${parts.join('. ')}.`
|
||||||
|
: 'No matching data to remove.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async assertSlugAvailable(slug: string, excludeId?: bigint) {
|
private async assertSlugAvailable(slug: string, excludeId?: bigint) {
|
||||||
const existing = await this.prisma.business.findUnique({ where: { slug } });
|
const existing = await this.prisma.business.findUnique({ where: { slug } });
|
||||||
if (existing && existing.id !== excludeId) {
|
if (existing && existing.id !== excludeId) {
|
||||||
@@ -471,6 +788,18 @@ export class BusinessAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertOldBusinessIdAvailable(oldBusinessId: bigint, excludeId?: bigint) {
|
||||||
|
const existing = await this.prisma.business.findFirst({
|
||||||
|
where: { oldBusinessId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (existing && existing.id !== excludeId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Old business id ${oldBusinessId} is already linked to "${existing.name}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async validateCategoryIds(categoryIds: number[]) {
|
private async validateCategoryIds(categoryIds: number[]) {
|
||||||
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
|
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
|
||||||
const count = await this.prisma.businessCategory.count({
|
const count = await this.prisma.businessCategory.count({
|
||||||
@@ -578,6 +907,7 @@ export class BusinessAdminService {
|
|||||||
about: string | null;
|
about: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
oldBusinessId: bigint | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
categoryAssignments: {
|
categoryAssignments: {
|
||||||
@@ -615,6 +945,8 @@ export class BusinessAdminService {
|
|||||||
about: business.about,
|
about: business.about,
|
||||||
slug: business.slug,
|
slug: business.slug,
|
||||||
isActive: business.isActive,
|
isActive: business.isActive,
|
||||||
|
oldBusinessId:
|
||||||
|
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
||||||
createdAt: business.createdAt,
|
createdAt: business.createdAt,
|
||||||
updatedAt: business.updatedAt,
|
updatedAt: business.updatedAt,
|
||||||
categories: business.categoryAssignments.map((a) => ({
|
categories: business.categoryAssignments.map((a) => ({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -52,4 +53,11 @@ export class CreateBusinessDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
ownerPassword!: string;
|
ownerPassword!: string;
|
||||||
|
|
||||||
|
/** Legacy WillaEngine businesses.id for later selective migration. */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
oldBusinessId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, IsIn, IsInt, IsPositive } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export const MIGRATE_FROM_OLD_ENTITIES = [
|
||||||
|
'product_categories',
|
||||||
|
'product',
|
||||||
|
'customer_categories',
|
||||||
|
'customer',
|
||||||
|
'blog_categories',
|
||||||
|
'blog',
|
||||||
|
'portfolio_categories',
|
||||||
|
'portfolio',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MigrateFromOldEntity = (typeof MIGRATE_FROM_OLD_ENTITIES)[number];
|
||||||
|
|
||||||
|
export class MigrateFromOldDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
oldBusinessId!: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(1)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsIn(MIGRATE_FROM_OLD_ENTITIES, { each: true })
|
||||||
|
entities!: MigrateFromOldEntity[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, IsIn } from 'class-validator';
|
||||||
|
import {
|
||||||
|
MIGRATE_FROM_OLD_ENTITIES,
|
||||||
|
MigrateFromOldEntity,
|
||||||
|
} from './migrate-from-old.dto';
|
||||||
|
|
||||||
|
/** Same entity keys as migrate-from-old — selective delete for re-migration. */
|
||||||
|
export const PURGE_BUSINESS_DATA_ENTITIES = MIGRATE_FROM_OLD_ENTITIES;
|
||||||
|
|
||||||
|
export type PurgeBusinessDataEntity = MigrateFromOldEntity;
|
||||||
|
|
||||||
|
export class PurgeBusinessDataDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(1)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsIn(PURGE_BUSINESS_DATA_ENTITIES, { each: true })
|
||||||
|
entities!: PurgeBusinessDataEntity[];
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -44,4 +45,12 @@ export class UpdateBusinessDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
ownerUserId?: number | null;
|
ownerUserId?: number | null;
|
||||||
|
|
||||||
|
/** Legacy WillaEngine businesses.id; null clears the link. */
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
oldBusinessId?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { MediaEntityType } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import { PurgeBusinessDataEntity } from './dto/purge-business-data.dto';
|
||||||
|
|
||||||
|
type ContentCategoryEntity =
|
||||||
|
| typeof MediaEntityType.portfolio
|
||||||
|
| typeof MediaEntityType.blog
|
||||||
|
| typeof MediaEntityType.customer
|
||||||
|
| typeof MediaEntityType.product;
|
||||||
|
|
||||||
|
export type PurgeEntityCounts = {
|
||||||
|
deleted: number;
|
||||||
|
imagesDeleted?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PurgeEntityResult =
|
||||||
|
| PurgeEntityCounts
|
||||||
|
| { status: 'not_implemented' };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LegacyPurgeService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async purgeEntities(
|
||||||
|
businessId: bigint,
|
||||||
|
entities: PurgeBusinessDataEntity[],
|
||||||
|
): Promise<Partial<Record<PurgeBusinessDataEntity, PurgeEntityResult>>> {
|
||||||
|
const selected = new Set(entities);
|
||||||
|
const results: Partial<
|
||||||
|
Record<PurgeBusinessDataEntity, PurgeEntityResult>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
// Items before categories so assignments clear cleanly with parent rows.
|
||||||
|
if (selected.has('product')) {
|
||||||
|
results.product = await this.purgeProducts(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('product_categories')) {
|
||||||
|
results.product_categories = await this.purgeProductCategories(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('portfolio')) {
|
||||||
|
results.portfolio = await this.purgePortfolios(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('portfolio_categories')) {
|
||||||
|
results.portfolio_categories =
|
||||||
|
await this.purgePortfolioCategories(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('blog')) {
|
||||||
|
results.blog = await this.purgeBlogs(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('blog_categories')) {
|
||||||
|
results.blog_categories = await this.purgeBlogCategories(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('customer')) {
|
||||||
|
results.customer = await this.purgeCustomers(businessId);
|
||||||
|
}
|
||||||
|
if (selected.has('customer_categories')) {
|
||||||
|
results.customer_categories =
|
||||||
|
await this.purgeCustomerCategories(businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (!results[entity]) {
|
||||||
|
results[entity] = { status: 'not_implemented' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeProducts(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||||
|
const products = await this.prisma.product.findMany({
|
||||||
|
where: { businessId },
|
||||||
|
select: { id: true, featuredMediaId: true },
|
||||||
|
});
|
||||||
|
const productIds = products.map((p) => p.id);
|
||||||
|
|
||||||
|
if (!productIds.length) {
|
||||||
|
return { deleted: 0, imagesDeleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.product,
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
select: { mediaId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaIds = new Set<bigint>();
|
||||||
|
for (const p of products) {
|
||||||
|
if (p.featuredMediaId != null) mediaIds.add(p.featuredMediaId);
|
||||||
|
}
|
||||||
|
for (const a of attachments) {
|
||||||
|
mediaIds.add(a.mediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.mediaAttachment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.product,
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.product,
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// order_items references products — must remove before deleting products.
|
||||||
|
await tx.orderItem.deleteMany({
|
||||||
|
where: { productId: { in: productIds } },
|
||||||
|
});
|
||||||
|
// shopping_card_items references products — must remove before deleting products.
|
||||||
|
await tx.shoppingCardItem.deleteMany({
|
||||||
|
where: { productId: { in: productIds } },
|
||||||
|
});
|
||||||
|
await tx.product.updateMany({
|
||||||
|
where: { businessId, id: { in: productIds } },
|
||||||
|
data: { featuredMediaId: null },
|
||||||
|
});
|
||||||
|
await tx.product.deleteMany({
|
||||||
|
where: { businessId, id: { in: productIds } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||||
|
|
||||||
|
return { deleted: productIds.length, imagesDeleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeProductCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
): Promise<PurgeEntityCounts> {
|
||||||
|
return this.purgeCategories(businessId, MediaEntityType.product);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgePortfolios(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||||
|
const portfolios = await this.prisma.portfolios.findMany({
|
||||||
|
where: { business_id: businessId },
|
||||||
|
select: { id: true, featured_media_id: true },
|
||||||
|
});
|
||||||
|
const portfolioIds = portfolios.map((p) => p.id);
|
||||||
|
|
||||||
|
if (!portfolioIds.length) {
|
||||||
|
return { deleted: 0, imagesDeleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.portfolio,
|
||||||
|
entityId: { in: portfolioIds },
|
||||||
|
},
|
||||||
|
select: { mediaId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaIds = new Set<bigint>();
|
||||||
|
for (const row of portfolios) {
|
||||||
|
if (row.featured_media_id != null) {
|
||||||
|
mediaIds.add(row.featured_media_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of attachments) {
|
||||||
|
mediaIds.add(row.mediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.mediaAttachment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.portfolio,
|
||||||
|
entityId: { in: portfolioIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.portfolio,
|
||||||
|
entityId: { in: portfolioIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.portfolios.updateMany({
|
||||||
|
where: { business_id: businessId, id: { in: portfolioIds } },
|
||||||
|
data: { featured_media_id: null },
|
||||||
|
});
|
||||||
|
await tx.portfolios.deleteMany({
|
||||||
|
where: { business_id: businessId, id: { in: portfolioIds } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: portfolioIds.length,
|
||||||
|
imagesDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgePortfolioCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
): Promise<PurgeEntityCounts> {
|
||||||
|
return this.purgeCategories(businessId, MediaEntityType.portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeBlogs(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||||
|
const blogs = await this.prisma.blogs.findMany({
|
||||||
|
where: { business_id: businessId },
|
||||||
|
select: { id: true, featured_media_id: true },
|
||||||
|
});
|
||||||
|
const blogIds = blogs.map((b) => b.id);
|
||||||
|
|
||||||
|
if (!blogIds.length) {
|
||||||
|
return { deleted: 0, imagesDeleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.blog,
|
||||||
|
entityId: { in: blogIds },
|
||||||
|
},
|
||||||
|
select: { mediaId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaIds = new Set<bigint>();
|
||||||
|
for (const row of blogs) {
|
||||||
|
if (row.featured_media_id != null) {
|
||||||
|
mediaIds.add(row.featured_media_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of attachments) {
|
||||||
|
mediaIds.add(row.mediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.mediaAttachment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.blog,
|
||||||
|
entityId: { in: blogIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.blog,
|
||||||
|
entityId: { in: blogIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.blogs.updateMany({
|
||||||
|
where: { business_id: businessId, id: { in: blogIds } },
|
||||||
|
data: { featured_media_id: null },
|
||||||
|
});
|
||||||
|
await tx.blogs.deleteMany({
|
||||||
|
where: { business_id: businessId, id: { in: blogIds } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: blogIds.length,
|
||||||
|
imagesDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeBlogCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
): Promise<PurgeEntityCounts> {
|
||||||
|
return this.purgeCategories(businessId, MediaEntityType.blog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeCustomers(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||||
|
const links = await this.prisma.businessCustomer.findMany({
|
||||||
|
where: { businessId },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
const userIds = links.map((l) => l.userId);
|
||||||
|
|
||||||
|
if (!userIds.length) {
|
||||||
|
return { deleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.customer,
|
||||||
|
entityId: { in: userIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.businessCustomer.deleteMany({
|
||||||
|
where: { businessId, userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deleted: links.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeCustomerCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
): Promise<PurgeEntityCounts> {
|
||||||
|
return this.purgeCategories(businessId, MediaEntityType.customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
entityType: ContentCategoryEntity,
|
||||||
|
): Promise<PurgeEntityCounts> {
|
||||||
|
const categories = await this.prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const categoryIds = categories.map((c) => c.id);
|
||||||
|
|
||||||
|
if (!categoryIds.length) {
|
||||||
|
return { deleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.categoryAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
categoryId: { in: categoryIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Break self-FK so deleteMany can remove the whole tree.
|
||||||
|
await tx.category.updateMany({
|
||||||
|
where: { id: { in: categoryIds } },
|
||||||
|
data: { parentId: null },
|
||||||
|
});
|
||||||
|
await tx.category.deleteMany({
|
||||||
|
where: { id: { in: categoryIds } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deleted: categoryIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteMediaIds(
|
||||||
|
businessId: bigint,
|
||||||
|
mediaIds: bigint[],
|
||||||
|
): Promise<number> {
|
||||||
|
if (!mediaIds.length) return 0;
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set(mediaIds.map((id) => id.toString()))].map(
|
||||||
|
(id) => BigInt(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stillLinked = new Set<string>();
|
||||||
|
|
||||||
|
const remainingAttachments = await this.prisma.mediaAttachment.findMany({
|
||||||
|
where: { businessId, mediaId: { in: uniqueIds } },
|
||||||
|
select: { mediaId: true },
|
||||||
|
});
|
||||||
|
for (const row of remainingAttachments) {
|
||||||
|
stillLinked.add(row.mediaId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuredOnPortfolios = await this.prisma.portfolios.findMany({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
featured_media_id: { in: uniqueIds },
|
||||||
|
},
|
||||||
|
select: { featured_media_id: true },
|
||||||
|
});
|
||||||
|
for (const row of featuredOnPortfolios) {
|
||||||
|
if (row.featured_media_id != null) {
|
||||||
|
stillLinked.add(row.featured_media_id.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuredOnProducts = await this.prisma.product.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
featuredMediaId: { in: uniqueIds },
|
||||||
|
},
|
||||||
|
select: { featuredMediaId: true },
|
||||||
|
});
|
||||||
|
for (const row of featuredOnProducts) {
|
||||||
|
if (row.featuredMediaId != null) {
|
||||||
|
stillLinked.add(row.featuredMediaId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuredOnBlogs = await this.prisma.blogs.findMany({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
featured_media_id: { in: uniqueIds },
|
||||||
|
},
|
||||||
|
select: { featured_media_id: true },
|
||||||
|
});
|
||||||
|
for (const row of featuredOnBlogs) {
|
||||||
|
if (row.featured_media_id != null) {
|
||||||
|
stillLinked.add(row.featured_media_id.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandImages = await this.prisma.brand.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
imageMediaId: { in: uniqueIds },
|
||||||
|
},
|
||||||
|
select: { imageMediaId: true },
|
||||||
|
});
|
||||||
|
for (const row of brandImages) {
|
||||||
|
if (row.imageMediaId != null) {
|
||||||
|
stillLinked.add(row.imageMediaId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessMedia = await this.prisma.business.findFirst({
|
||||||
|
where: {
|
||||||
|
id: businessId,
|
||||||
|
OR: [
|
||||||
|
{ logoMediaId: { in: uniqueIds } },
|
||||||
|
{ faviconMediaId: { in: uniqueIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { logoMediaId: true, faviconMediaId: true },
|
||||||
|
});
|
||||||
|
if (businessMedia?.logoMediaId != null) {
|
||||||
|
stillLinked.add(businessMedia.logoMediaId.toString());
|
||||||
|
}
|
||||||
|
if (businessMedia?.faviconMediaId != null) {
|
||||||
|
stillLinked.add(businessMedia.faviconMediaId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderSlides = await this.prisma.website_slider_slides.findMany({
|
||||||
|
where: {
|
||||||
|
image_media_id: { in: uniqueIds },
|
||||||
|
website_sliders: { business_id: businessId },
|
||||||
|
},
|
||||||
|
select: { image_media_id: true },
|
||||||
|
});
|
||||||
|
for (const row of sliderSlides) {
|
||||||
|
stillLinked.add(row.image_media_id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletableIds = uniqueIds.filter(
|
||||||
|
(id) => !stillLinked.has(id.toString()),
|
||||||
|
);
|
||||||
|
if (!deletableIds.length) return 0;
|
||||||
|
|
||||||
|
const mediaRows = await this.prisma.media.findMany({
|
||||||
|
where: { businessId, id: { in: deletableIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
storagePath: true,
|
||||||
|
storageDisk: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.media.deleteMany({
|
||||||
|
where: { businessId, id: { in: mediaRows.map((m) => m.id) } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of mediaRows) {
|
||||||
|
try {
|
||||||
|
await this.storage.delete(row.storagePath, row.storageDisk);
|
||||||
|
} catch {
|
||||||
|
// DB row removed; orphaned object can be cleaned later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaRows.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import sharp from 'sharp';
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { businessBrandFaviconKey } from '../storage/storage-keys';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||||
import {
|
import {
|
||||||
@@ -251,7 +252,7 @@ export class BusinessProfileService {
|
|||||||
) {
|
) {
|
||||||
const business = await this.prisma.business.findUnique({
|
const business = await this.prisma.business.findUnique({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
select: { slug: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!business) return;
|
if (!business) return;
|
||||||
|
|
||||||
@@ -302,7 +303,7 @@ export class BusinessProfileService {
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const fileName = `${randomUUID()}-favicon.png`;
|
const fileName = `${randomUUID()}-favicon.png`;
|
||||||
const storageKey = `businesses/${business.slug}/${businessId}/media/${fileName}`;
|
const storageKey = businessBrandFaviconKey(businessId, fileName);
|
||||||
const stored = await this.storage.upload({
|
const stored = await this.storage.upload({
|
||||||
key: storageKey,
|
key: storageKey,
|
||||||
body: faviconBuffer,
|
body: faviconBuffer,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { LegacyMysqlService } from './legacy-mysql.service';
|
||||||
|
import { LegacySourceS3Service } from './legacy-source-s3.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [LegacyMysqlService, LegacySourceS3Service],
|
||||||
|
exports: [LegacyMysqlService, LegacySourceS3Service],
|
||||||
|
})
|
||||||
|
export class LegacyMysqlModule {}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import mysql, { Pool, PoolOptions, RowDataPacket } from 'mysql2/promise';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LegacyMysqlService implements OnModuleDestroy {
|
||||||
|
private pool: Pool | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.pool) {
|
||||||
|
await this.pool.end();
|
||||||
|
this.pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.config.get<string>('OLD_MYSQL_HOST') &&
|
||||||
|
this.config.get<string>('OLD_MYSQL_USER') &&
|
||||||
|
this.config.get<string>('OLD_MYSQL_DATABASE'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async query<T extends RowDataPacket[]>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): Promise<T> {
|
||||||
|
const pool = this.getPool();
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query<T>(sql, params);
|
||||||
|
return rows;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown MySQL error';
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Old CMS MySQL query failed: ${message}. Ensure the SSH tunnel is up and OLD_MYSQL_* is set.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPool(): Pool {
|
||||||
|
if (this.pool) {
|
||||||
|
return this.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Old CMS MySQL is not configured. Set OLD_MYSQL_HOST, OLD_MYSQL_USER, OLD_MYSQL_DATABASE (and password/port).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: PoolOptions = {
|
||||||
|
host: this.config.getOrThrow<string>('OLD_MYSQL_HOST'),
|
||||||
|
port: Number(this.config.get<string>('OLD_MYSQL_PORT', '3307')),
|
||||||
|
user: this.config.getOrThrow<string>('OLD_MYSQL_USER'),
|
||||||
|
password: this.config.get<string>('OLD_MYSQL_PASSWORD', ''),
|
||||||
|
database: this.config.getOrThrow<string>('OLD_MYSQL_DATABASE'),
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 4,
|
||||||
|
namedPlaceholders: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pool = mysql.createPool(options);
|
||||||
|
return this.pool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LegacySourceS3Service implements OnModuleDestroy {
|
||||||
|
private client: S3Client | null = null;
|
||||||
|
private bucket = '';
|
||||||
|
private publicUrlBase = '';
|
||||||
|
private readonly prefixCache = new Map<number, string>();
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
this.client?.destroy();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.config.get<string>('OLD_S3_ENDPOINT') &&
|
||||||
|
this.config.get<string>('OLD_S3_BUCKET') &&
|
||||||
|
this.config.get<string>('OLD_S3_ACCESS_KEY_ID') &&
|
||||||
|
this.config.get<string>('OLD_S3_SECRET_ACCESS_KEY'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
spatieObjectKey(prefix: string, mediaId: number, fileName: string): string {
|
||||||
|
const hash = createHash('md5').update(String(mediaId)).digest('hex');
|
||||||
|
return `${prefix}/${hash}/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveBusinessPrefix(
|
||||||
|
oldBusinessId: number,
|
||||||
|
slugHint: string | null,
|
||||||
|
probes: Array<{ id: number; fileName: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
const cached = this.prefixCache.get(oldBusinessId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const add = (prefix: string) => {
|
||||||
|
if (!prefix || seen.has(prefix)) return;
|
||||||
|
seen.add(prefix);
|
||||||
|
candidates.push(prefix);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (slugHint?.trim()) {
|
||||||
|
const slug = slugHint.trim();
|
||||||
|
add(`${slug}_${oldBusinessId}`);
|
||||||
|
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = await this.listTopLevelPrefixes();
|
||||||
|
for (const prefix of listed) {
|
||||||
|
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
||||||
|
add(prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidates.length) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Could not resolve old S3 path prefix for business ${oldBusinessId}. Check OLD_S3_* and that files exist.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = probes.slice(0, 12);
|
||||||
|
let bestPrefix = candidates[0];
|
||||||
|
let bestHits = -1;
|
||||||
|
|
||||||
|
for (const prefix of candidates) {
|
||||||
|
let hits = 0;
|
||||||
|
for (const probe of sample) {
|
||||||
|
const key = this.spatieObjectKey(prefix, probe.id, probe.fileName);
|
||||||
|
if (await this.objectExists(key)) {
|
||||||
|
hits += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hits > bestHits) {
|
||||||
|
bestHits = hits;
|
||||||
|
bestPrefix = prefix;
|
||||||
|
}
|
||||||
|
if (bestHits === sample.length && sample.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestHits <= 0 && sample.length > 0) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prefixCache.set(oldBusinessId, bestPrefix);
|
||||||
|
return bestPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectBuffer(key: string): Promise<Buffer> {
|
||||||
|
const { client, bucket } = this.getClient();
|
||||||
|
try {
|
||||||
|
const result = await client.send(
|
||||||
|
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||||
|
);
|
||||||
|
if (!result.Body) {
|
||||||
|
throw new Error(`Empty body for ${key}`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await result.Body.transformToByteArray());
|
||||||
|
} catch (err) {
|
||||||
|
// Public URL fallback (path-style bucket public reads)
|
||||||
|
if (this.publicUrlBase) {
|
||||||
|
const res = await fetch(`${this.publicUrlBase}/${key}`);
|
||||||
|
if (res.ok) {
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown S3 error';
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Old CMS S3 get failed for ${key}: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async objectExists(key: string): Promise<boolean> {
|
||||||
|
const { client, bucket } = this.getClient();
|
||||||
|
try {
|
||||||
|
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (this.publicUrlBase) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.publicUrlBase}/${key}`, {
|
||||||
|
method: 'HEAD',
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listTopLevelPrefixes(): Promise<string[]> {
|
||||||
|
const { client, bucket } = this.getClient();
|
||||||
|
const prefixes: string[] = [];
|
||||||
|
let token: string | undefined;
|
||||||
|
do {
|
||||||
|
const res = await client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Delimiter: '/',
|
||||||
|
ContinuationToken: token,
|
||||||
|
MaxKeys: 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const p of res.CommonPrefixes ?? []) {
|
||||||
|
const raw = p.Prefix?.replace(/\/$/, '');
|
||||||
|
if (raw) prefixes.push(raw);
|
||||||
|
}
|
||||||
|
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||||
|
} while (token);
|
||||||
|
return prefixes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClient(): { client: S3Client; bucket: string } {
|
||||||
|
if (this.client) {
|
||||||
|
return { client: this.client, bucket: this.bucket };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Old CMS S3 is not configured. Set OLD_S3_ENDPOINT, OLD_S3_BUCKET, OLD_S3_ACCESS_KEY_ID, OLD_S3_SECRET_ACCESS_KEY.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bucket = this.config.getOrThrow<string>('OLD_S3_BUCKET');
|
||||||
|
this.publicUrlBase = (
|
||||||
|
this.config.get<string>('OLD_S3_PUBLIC_URL') ?? ''
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint: this.config.getOrThrow<string>('OLD_S3_ENDPOINT'),
|
||||||
|
region: this.config.get<string>('OLD_S3_REGION', 'us-east-1'),
|
||||||
|
forcePathStyle:
|
||||||
|
this.config.get<string>('OLD_S3_FORCE_PATH_STYLE', 'true') === 'true',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.config.getOrThrow<string>('OLD_S3_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: this.config.getOrThrow<string>(
|
||||||
|
'OLD_S3_SECRET_ACCESS_KEY',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { client: this.client, bucket: this.bucket };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import sharp from 'sharp';
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { businessMediaKey } from '../storage/storage-keys';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
import { ListMediaDto } from './dto/list-media.dto';
|
import { ListMediaDto } from './dto/list-media.dto';
|
||||||
import { UpdateMediaDto } from './dto/update-media.dto';
|
import { UpdateMediaDto } from './dto/update-media.dto';
|
||||||
@@ -92,7 +93,7 @@ export class MediaService {
|
|||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
items.push(await this.uploadOne(businessId, business.slug, file, actor.id));
|
items.push(await this.uploadOne(businessId, file, actor.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
@@ -153,7 +154,6 @@ export class MediaService {
|
|||||||
|
|
||||||
private async uploadOne(
|
private async uploadOne(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
businessSlug: string,
|
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
uploadedBy: bigint,
|
uploadedBy: bigint,
|
||||||
) {
|
) {
|
||||||
@@ -201,7 +201,7 @@ export class MediaService {
|
|||||||
|
|
||||||
const ext = this.extensionFromContentType(contentType);
|
const ext = this.extensionFromContentType(contentType);
|
||||||
const fileName = `${randomUUID()}${ext}`;
|
const fileName = `${randomUUID()}${ext}`;
|
||||||
const storageKey = `businesses/${businessSlug}/${businessId}/media/${fileName}`;
|
const storageKey = businessMediaKey(businessId, fileName);
|
||||||
|
|
||||||
const stored = await this.storage.upload({
|
const stored = await this.storage.upload({
|
||||||
key: storageKey,
|
key: storageKey,
|
||||||
|
|||||||
@@ -60,9 +60,20 @@ export class ListPublicPortfoliosDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreatePortfolioDto {
|
export class CreatePortfolioDto {
|
||||||
|
/** @deprecated Prefer titleFa — kept for older clients. */
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
title!: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
titleFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleEn?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -107,11 +118,21 @@ export class CreatePortfolioDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdatePortfolioDto {
|
export class UpdatePortfolioDto {
|
||||||
|
/** @deprecated Prefer titleFa — kept for older clients. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
titleFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
titleEn?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
abstract?: string | null;
|
abstract?: string | null;
|
||||||
|
|||||||
@@ -109,9 +109,15 @@ export class PortfoliosService {
|
|||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
await this.assertPermission(businessId, actor.id, 'portfolios.create');
|
await this.assertPermission(businessId, actor.id, 'portfolios.create');
|
||||||
|
|
||||||
|
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
|
||||||
|
const titleEn = dto.titleEn?.trim() || null;
|
||||||
|
if (!titleFa) {
|
||||||
|
throw new BadRequestException('titleFa is required');
|
||||||
|
}
|
||||||
|
|
||||||
const slug = await this.ensureUniqueSlug(
|
const slug = await this.ensureUniqueSlug(
|
||||||
businessId,
|
businessId,
|
||||||
dto.slug ?? slugify(dto.title),
|
dto.slug ?? slugify(titleEn || titleFa),
|
||||||
);
|
);
|
||||||
|
|
||||||
const status = dto.status ?? ContentStatus.draft;
|
const status = dto.status ?? ContentStatus.draft;
|
||||||
@@ -139,7 +145,9 @@ export class PortfoliosService {
|
|||||||
const portfolio = await tx.portfolios.create({
|
const portfolio = await tx.portfolios.create({
|
||||||
data: {
|
data: {
|
||||||
business_id: businessId,
|
business_id: businessId,
|
||||||
title: dto.title.trim(),
|
title: titleFa,
|
||||||
|
title_fa: titleFa,
|
||||||
|
title_en: titleEn,
|
||||||
slug,
|
slug,
|
||||||
description: dto.abstract?.trim() || null,
|
description: dto.abstract?.trim() || null,
|
||||||
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
|
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
|
||||||
@@ -198,12 +206,30 @@ export class PortfoliosService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let slug = existing.slug;
|
let slug = existing.slug;
|
||||||
|
const nextTitleFaRaw =
|
||||||
|
dto.titleFa !== undefined || dto.title !== undefined
|
||||||
|
? this.resolveTitleFa(dto.titleFa, dto.title)
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
(dto.titleFa !== undefined || dto.title !== undefined) &&
|
||||||
|
!nextTitleFaRaw
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('titleFa is required');
|
||||||
|
}
|
||||||
|
const nextTitleFa = nextTitleFaRaw ?? undefined;
|
||||||
if (dto.slug) {
|
if (dto.slug) {
|
||||||
slug = await this.ensureUniqueSlug(businessId, dto.slug, portfolioId);
|
slug = await this.ensureUniqueSlug(businessId, dto.slug, portfolioId);
|
||||||
} else if (dto.title && dto.title !== existing.title) {
|
} else if (
|
||||||
|
nextTitleFa &&
|
||||||
|
nextTitleFa !== (existing.title_fa ?? existing.title)
|
||||||
|
) {
|
||||||
|
const slugSource =
|
||||||
|
(dto.titleEn !== undefined
|
||||||
|
? dto.titleEn?.trim() || null
|
||||||
|
: existing.title_en) || nextTitleFa;
|
||||||
slug = await this.ensureUniqueSlug(
|
slug = await this.ensureUniqueSlug(
|
||||||
businessId,
|
businessId,
|
||||||
slugify(dto.title),
|
slugify(slugSource),
|
||||||
portfolioId,
|
portfolioId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -245,23 +271,37 @@ export class PortfoliosService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.$transaction(async (tx) => {
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const data: Prisma.portfoliosUncheckedUpdateInput = {
|
||||||
|
slug,
|
||||||
|
content: nextContent as Prisma.InputJsonValue,
|
||||||
|
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
if (nextTitleFa !== undefined) {
|
||||||
|
data.title = nextTitleFa;
|
||||||
|
data.title_fa = nextTitleFa;
|
||||||
|
}
|
||||||
|
if (dto.titleEn !== undefined) {
|
||||||
|
data.title_en = dto.titleEn?.trim() || null;
|
||||||
|
}
|
||||||
|
if (dto.abstract !== undefined) {
|
||||||
|
data.description = dto.abstract?.trim() || null;
|
||||||
|
}
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
data.status = dto.status;
|
||||||
|
}
|
||||||
|
if (featuredMediaId !== undefined) {
|
||||||
|
data.featured_media_id = featuredMediaId;
|
||||||
|
}
|
||||||
|
if (dto.sortOrder !== undefined) {
|
||||||
|
data.sort_order = dto.sortOrder;
|
||||||
|
}
|
||||||
|
if (publishedAt !== undefined) {
|
||||||
|
data.published_at = publishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
const portfolio = await tx.portfolios.update({
|
const portfolio = await tx.portfolios.update({
|
||||||
where: { id: portfolioId },
|
where: { id: portfolioId },
|
||||||
data: {
|
data,
|
||||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
|
||||||
...(dto.abstract !== undefined
|
|
||||||
? { description: dto.abstract?.trim() || null }
|
|
||||||
: {}),
|
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
|
||||||
...(featuredMediaId !== undefined
|
|
||||||
? { featured_media_id: featuredMediaId }
|
|
||||||
: {}),
|
|
||||||
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
|
|
||||||
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
|
|
||||||
slug,
|
|
||||||
content: nextContent as Prisma.InputJsonValue,
|
|
||||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
include: portfolioInclude,
|
include: portfolioInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -540,12 +580,36 @@ export class PortfoliosService {
|
|||||||
...(entityIds ? { id: { in: entityIds } } : {}),
|
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||||
...(query.title?.trim()
|
...(query.title?.trim()
|
||||||
? {
|
? {
|
||||||
title: { contains: query.title.trim(), mode: 'insensitive' },
|
OR: [
|
||||||
|
{
|
||||||
|
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title_fa: {
|
||||||
|
contains: query.title.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title_en: {
|
||||||
|
contains: query.title.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveTitleFa(
|
||||||
|
titleFa?: string | null,
|
||||||
|
title?: string | null,
|
||||||
|
): string | null {
|
||||||
|
const value = (titleFa ?? title)?.trim() || '';
|
||||||
|
return value.length >= 2 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
private async findPortfolioOrThrow(businessId: bigint, portfolioId: bigint) {
|
private async findPortfolioOrThrow(businessId: bigint, portfolioId: bigint) {
|
||||||
const portfolio = await this.prisma.portfolios.findFirst({
|
const portfolio = await this.prisma.portfolios.findFirst({
|
||||||
where: { id: portfolioId, business_id: businessId },
|
where: { id: portfolioId, business_id: businessId },
|
||||||
@@ -619,7 +683,9 @@ export class PortfoliosService {
|
|||||||
return {
|
return {
|
||||||
id: portfolio.id.toString(),
|
id: portfolio.id.toString(),
|
||||||
businessId: portfolio.business_id.toString(),
|
businessId: portfolio.business_id.toString(),
|
||||||
title: portfolio.title,
|
title: portfolio.title_fa?.trim() || portfolio.title,
|
||||||
|
titleFa: portfolio.title_fa?.trim() || portfolio.title,
|
||||||
|
titleEn: portfolio.title_en?.trim() || null,
|
||||||
slug: portfolio.slug,
|
slug: portfolio.slug,
|
||||||
abstract: portfolio.description ?? '',
|
abstract: portfolio.description ?? '',
|
||||||
mainTextHtml: (content.html as string | undefined) ?? '',
|
mainTextHtml: (content.html as string | undefined) ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Parspack S3 object key layout (path-style public URLs):
|
||||||
|
*
|
||||||
|
* meshkee/
|
||||||
|
* businesses/
|
||||||
|
* {businessId}/
|
||||||
|
* media/{uuid}{ext} — media library uploads
|
||||||
|
* brand/favicon-{uuid}.png — derived favicons
|
||||||
|
*/
|
||||||
|
const ROOT = 'meshkee';
|
||||||
|
|
||||||
|
export function businessMediaKey(
|
||||||
|
businessId: bigint | string | number,
|
||||||
|
fileName: string,
|
||||||
|
): string {
|
||||||
|
return `${ROOT}/businesses/${businessId}/media/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function businessBrandFaviconKey(
|
||||||
|
businessId: bigint | string | number,
|
||||||
|
fileName: string,
|
||||||
|
): string {
|
||||||
|
return `${ROOT}/businesses/${businessId}/brand/${fileName}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user