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
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
+23
-4
@@ -24,16 +24,35 @@ JWT_REFRESH_EXPIRES_IN=7d
|
||||
# SMS (set to true when SMS provider API is ready)
|
||||
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
|
||||
S3_ENDPOINT=https://sas.amin.parminstorage.ir
|
||||
S3_BUCKET=meshkee-storage
|
||||
S3_PUBLIC_URL=https://meshkee-storage.sas.amin.parminstorage.ir
|
||||
S3_ENDPOINT=https://c804387.parspack.net
|
||||
S3_BUCKET=c804387
|
||||
S3_PUBLIC_URL=https://c804387.parspack.net/c804387
|
||||
S3_REGION=us-east-1
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
S3_ACCESS_KEY_ID=
|
||||
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_PROVIDER=groq
|
||||
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) |
|
||||
| Auth | JWT (access + refresh), Passport, bcrypt |
|
||||
| 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
|
||||
|
||||
- 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)
|
||||
- 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` |
|
||||
| SMS | `SMS_ENABLED` |
|
||||
| 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` |
|
||||
|
||||
---
|
||||
@@ -540,6 +543,7 @@ See `.env.example` for the full list. Key groups:
|
||||
|
||||
- Multi-tenant auth (register, login, OTP, profile)
|
||||
- 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
|
||||
- Media upload (S3 + Sharp)
|
||||
- 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 |
|
||||
|---------|----|-------------|-----|--------|
|
||||
| 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 |
|
||||
| SMS provider | — | — | Stub | — |
|
||||
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
|
||||
|
||||
Generated
+95
@@ -20,6 +20,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"mysql2": "^3.23.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -2424,6 +2425,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -3760,6 +3770,15 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -4104,6 +4123,12 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
@@ -4408,6 +4439,21 @@
|
||||
"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": {
|
||||
"version": "0.30.17",
|
||||
"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_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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -5680,6 +5760,21 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"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-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"mysql2": "^3.23.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
+208
-200
@@ -20,8 +20,8 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
profile Json @default("{}")
|
||||
oldId BigInt? @map("old_id")
|
||||
addresses Address[]
|
||||
blogs blogs[]
|
||||
businessCustomers BusinessCustomer[]
|
||||
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
|
||||
businessUsers BusinessUser[] @relation("BusinessMember")
|
||||
@@ -29,6 +29,7 @@ model User {
|
||||
commentsApproved Comment[] @relation("CommentApprover")
|
||||
expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover")
|
||||
favorites Favorite[]
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
||||
mediaUploaded Media[]
|
||||
ordersCreated Order[] @relation("OrderCreator")
|
||||
orders Order[] @relation("OrderCustomer")
|
||||
@@ -36,8 +37,9 @@ model User {
|
||||
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
||||
transactionsCreated Transaction[] @relation("TransactionCreator")
|
||||
transactions Transaction[] @relation("TransactionCustomer")
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
||||
userRoles UserRole[]
|
||||
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
||||
|
||||
@@index([cellNumber], map: "idx_users_cell_number")
|
||||
@@map("users")
|
||||
@@ -60,14 +62,15 @@ model Business {
|
||||
socialMedia Json @default("{}") @map("social_media")
|
||||
logoMediaId BigInt? @map("logo_media_id")
|
||||
faviconMediaId BigInt? @map("favicon_media_id")
|
||||
oldBusinessId BigInt? @map("old_business_id")
|
||||
addresses Address[]
|
||||
blogs blogs[]
|
||||
brands Brand[]
|
||||
categoryAssignments BusinessCategoryAssignment[]
|
||||
businessCustomers BusinessCustomer[]
|
||||
businessUsers BusinessUser[]
|
||||
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], 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[]
|
||||
categories Category[]
|
||||
contentCategoryAssignments CategoryAssignment[]
|
||||
@@ -78,6 +81,10 @@ model Business {
|
||||
domains Domain[]
|
||||
expertReviews ExpertReview[]
|
||||
favorites Favorite[]
|
||||
invoiceItemTemplates InvoiceItemTemplate[]
|
||||
invoiceTemplates InvoiceTemplate[]
|
||||
invoices Invoice[] @relation("InvoiceBusiness")
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||
media Media[]
|
||||
mediaAttachments MediaAttachment[]
|
||||
orders Order[]
|
||||
@@ -89,10 +96,6 @@ model Business {
|
||||
storeItems StoreItem[]
|
||||
storeSpecials StoreSpecial[]
|
||||
transactions Transaction[]
|
||||
invoices Invoice[] @relation("InvoiceBusiness")
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||
invoiceItemTemplates InvoiceItemTemplate[]
|
||||
invoiceTemplates InvoiceTemplate[]
|
||||
website_brand_groups website_brand_groups[]
|
||||
website_category_groups website_category_groups[]
|
||||
website_sliders website_sliders[]
|
||||
@@ -133,20 +136,20 @@ model BusinessCategoryAssignment {
|
||||
}
|
||||
|
||||
model Domain {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
host String @unique(map: "domains_host_unique") @db.VarChar(253)
|
||||
isPrimary Boolean @default(false) @map("is_primary")
|
||||
isVerified Boolean @default(false) @map("is_verified")
|
||||
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
||||
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
|
||||
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
host String @unique(map: "domains_host_unique") @db.VarChar(253)
|
||||
isPrimary Boolean @default(false) @map("is_primary")
|
||||
isVerified Boolean @default(false) @map("is_verified")
|
||||
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
||||
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
|
||||
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([businessId], map: "idx_domains_business_id")
|
||||
@@index([host], map: "idx_domains_host")
|
||||
@@ -263,10 +266,11 @@ model Media {
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
oldId BigInt? @map("old_id")
|
||||
blogs blogs[]
|
||||
brandImages Brand[]
|
||||
logoBusinesses Business[] @relation("BusinessLogo")
|
||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||
logoBusinesses Business[] @relation("BusinessLogo")
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
||||
attachments MediaAttachment[]
|
||||
@@ -298,7 +302,7 @@ model MediaAttachment {
|
||||
@@map("media_attachments")
|
||||
}
|
||||
|
||||
model Category {
|
||||
model Category {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
entityType MediaEntityType @map("entity_type")
|
||||
@@ -311,6 +315,7 @@ model Category {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
nameFa String? @map("name_fa") @db.VarChar(255)
|
||||
oldId BigInt? @map("old_id")
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
||||
children Category[] @relation("CategoryTree")
|
||||
@@ -610,7 +615,8 @@ model blogs {
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
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)
|
||||
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@ -624,7 +630,10 @@ model blogs {
|
||||
model portfolios {
|
||||
id BigInt @id @default(autoincrement())
|
||||
business_id BigInt
|
||||
author_id BigInt?
|
||||
title String @db.VarChar(255)
|
||||
title_fa String? @db.VarChar(255)
|
||||
title_en String? @db.VarChar(255)
|
||||
slug String @db.VarChar(255)
|
||||
description String?
|
||||
content Json @default("{}")
|
||||
@@ -637,6 +646,8 @@ model portfolios {
|
||||
metadata Json @default("{}")
|
||||
created_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)
|
||||
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, published_at(sort: Desc)], map: "idx_portfolios_business_published")
|
||||
@@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.
|
||||
@@ -651,7 +663,6 @@ model Address {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt? @map("user_id")
|
||||
businessId BigInt? @map("business_id")
|
||||
label String? @db.VarChar(100)
|
||||
province String @db.VarChar(100)
|
||||
city String @db.VarChar(100)
|
||||
address String
|
||||
@@ -659,6 +670,7 @@ model Address {
|
||||
landline String? @db.VarChar(30)
|
||||
createdAt DateTime @default(now()) @map("created_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)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
orders Order[]
|
||||
@@ -1039,6 +1051,175 @@ model website_sliders {
|
||||
@@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 {
|
||||
image
|
||||
video
|
||||
@@ -1050,6 +1231,7 @@ enum MediaEntityType {
|
||||
product
|
||||
blog
|
||||
portfolio
|
||||
customer
|
||||
|
||||
@@map("media_entity_type")
|
||||
}
|
||||
@@ -1141,183 +1323,9 @@ enum InvoiceOwnerScope {
|
||||
enum InvoiceStatus {
|
||||
draft
|
||||
issued
|
||||
approved
|
||||
paid
|
||||
cancelled
|
||||
approved
|
||||
|
||||
@@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 { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
LegacyMysqlModule,
|
||||
AuthModule,
|
||||
BusinessTeamModule,
|
||||
BusinessAdminModule,
|
||||
|
||||
@@ -35,13 +35,13 @@ function slugify(value: string): string {
|
||||
type BlogWithRelations = Prisma.blogsGetPayload<{
|
||||
include: {
|
||||
media: true;
|
||||
users: { select: { id: true; firstName: true; lastName: true; email: true } };
|
||||
author: { select: { id: true; firstName: true; lastName: true; email: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const blogInclude = {
|
||||
media: true,
|
||||
users: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
@@ -562,12 +562,12 @@ export class BlogsService {
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
authorId: blog.author_id?.toString() ?? null,
|
||||
author: blog.users
|
||||
author: blog.author
|
||||
? {
|
||||
id: blog.users.id.toString(),
|
||||
firstName: blog.users.firstName,
|
||||
lastName: blog.users.lastName,
|
||||
email: blog.users.email,
|
||||
id: blog.author.id.toString(),
|
||||
firstName: blog.author.firstName,
|
||||
lastName: blog.author.lastName,
|
||||
email: blog.author.email,
|
||||
}
|
||||
: 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 { DisableBusinessDto } from './dto/disable-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';
|
||||
|
||||
@Controller('businesses')
|
||||
@@ -55,6 +57,26 @@ export class BusinessAdminController {
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
addDomain(
|
||||
|
||||
@@ -4,11 +4,17 @@ import { BusinessCategoriesController } from './business-categories.controller';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
import { BusinessAdminController } from './business-admin.controller';
|
||||
import { BusinessAdminService } from './business-admin.service';
|
||||
import { LegacyMigrateService } from './legacy-migrate.service';
|
||||
import { LegacyPurgeService } from './legacy-purge.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||
providers: [BusinessAdminService, BusinessCategoriesService],
|
||||
providers: [
|
||||
BusinessAdminService,
|
||||
BusinessCategoriesService,
|
||||
LegacyMigrateService,
|
||||
LegacyPurgeService,
|
||||
],
|
||||
})
|
||||
export class BusinessAdminModule {}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
||||
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
||||
import { 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 type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||
|
||||
@@ -28,6 +32,7 @@ type BusinessRow = {
|
||||
slug: string;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
oldBusinessId: bigint | null;
|
||||
domainId: bigint | null;
|
||||
domain: string | null;
|
||||
sslEnabled: boolean | null;
|
||||
@@ -50,6 +55,8 @@ export class BusinessAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly legacyMigrate: LegacyMigrateService,
|
||||
private readonly legacyPurge: LegacyPurgeService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
@@ -99,6 +106,7 @@ export class BusinessAdminService {
|
||||
b.slug AS "slug",
|
||||
b.created_at AS "createdAt",
|
||||
b.is_active AS "isActive",
|
||||
b.old_business_id AS "oldBusinessId",
|
||||
dom.id AS "domainId",
|
||||
dom.host AS "domain",
|
||||
dom.ssl_enabled AS "sslEnabled",
|
||||
@@ -279,29 +287,46 @@ export class BusinessAdminService {
|
||||
await this.assertSlugAvailable(slug);
|
||||
await this.validateCategoryIds(dto.categoryIds);
|
||||
|
||||
const business = await this.prisma.$transaction(async (tx) => {
|
||||
const owner = await this.createOwnerUser(tx, dto);
|
||||
if (dto.oldBusinessId !== undefined) {
|
||||
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId));
|
||||
}
|
||||
|
||||
const created = await tx.business.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa.trim(),
|
||||
about: dto.about?.trim() ?? null,
|
||||
slug,
|
||||
},
|
||||
let business;
|
||||
try {
|
||||
business = await this.prisma.$transaction(async (tx) => {
|
||||
const owner = await this.createOwnerUser(tx, dto);
|
||||
|
||||
const created = await tx.business.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa.trim(),
|
||||
about: dto.about?.trim() ?? null,
|
||||
slug,
|
||||
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;
|
||||
});
|
||||
|
||||
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) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Business slug or old business id is already taken');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return this.getOne(business.id.toString(), actor);
|
||||
}
|
||||
@@ -335,6 +360,10 @@ export class BusinessAdminService {
|
||||
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 tx.business.update({
|
||||
where: { id: businessId },
|
||||
@@ -343,6 +372,12 @@ export class BusinessAdminService {
|
||||
nameFa: nextNameFa,
|
||||
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
|
||||
slug: nextSlug,
|
||||
...(dto.oldBusinessId !== undefined
|
||||
? {
|
||||
oldBusinessId:
|
||||
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -464,6 +499,288 @@ export class BusinessAdminService {
|
||||
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) {
|
||||
const existing = await this.prisma.business.findUnique({ where: { slug } });
|
||||
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[]) {
|
||||
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
|
||||
const count = await this.prisma.businessCategory.count({
|
||||
@@ -578,6 +907,7 @@ export class BusinessAdminService {
|
||||
about: string | null;
|
||||
slug: string;
|
||||
isActive: boolean;
|
||||
oldBusinessId: bigint | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
categoryAssignments: {
|
||||
@@ -615,6 +945,8 @@ export class BusinessAdminService {
|
||||
about: business.about,
|
||||
slug: business.slug,
|
||||
isActive: business.isActive,
|
||||
oldBusinessId:
|
||||
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
||||
createdAt: business.createdAt,
|
||||
updatedAt: business.updatedAt,
|
||||
categories: business.categoryAssignments.map((a) => ({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
@@ -52,4 +53,11 @@ export class CreateBusinessDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
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,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
@@ -44,4 +45,12 @@ export class UpdateBusinessDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
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 { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { businessBrandFaviconKey } from '../storage/storage-keys';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||
import {
|
||||
@@ -251,7 +252,7 @@ export class BusinessProfileService {
|
||||
) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { slug: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!business) return;
|
||||
|
||||
@@ -302,7 +303,7 @@ export class BusinessProfileService {
|
||||
.toBuffer();
|
||||
|
||||
const fileName = `${randomUUID()}-favicon.png`;
|
||||
const storageKey = `businesses/${business.slug}/${businessId}/media/${fileName}`;
|
||||
const storageKey = businessBrandFaviconKey(businessId, fileName);
|
||||
const stored = await this.storage.upload({
|
||||
key: storageKey,
|
||||
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 { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { businessMediaKey } from '../storage/storage-keys';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ListMediaDto } from './dto/list-media.dto';
|
||||
import { UpdateMediaDto } from './dto/update-media.dto';
|
||||
@@ -92,7 +93,7 @@ export class MediaService {
|
||||
|
||||
const items = [];
|
||||
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 };
|
||||
@@ -153,7 +154,6 @@ export class MediaService {
|
||||
|
||||
private async uploadOne(
|
||||
businessId: bigint,
|
||||
businessSlug: string,
|
||||
file: Express.Multer.File,
|
||||
uploadedBy: bigint,
|
||||
) {
|
||||
@@ -201,7 +201,7 @@ export class MediaService {
|
||||
|
||||
const ext = this.extensionFromContentType(contentType);
|
||||
const fileName = `${randomUUID()}${ext}`;
|
||||
const storageKey = `businesses/${businessSlug}/${businessId}/media/${fileName}`;
|
||||
const storageKey = businessMediaKey(businessId, fileName);
|
||||
|
||||
const stored = await this.storage.upload({
|
||||
key: storageKey,
|
||||
|
||||
@@ -60,9 +60,20 @@ export class ListPublicPortfoliosDto {
|
||||
}
|
||||
|
||||
export class CreatePortfolioDto {
|
||||
/** @deprecated Prefer titleFa — kept for older clients. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title!: string;
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
titleFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -107,11 +118,21 @@ export class CreatePortfolioDto {
|
||||
}
|
||||
|
||||
export class UpdatePortfolioDto {
|
||||
/** @deprecated Prefer titleFa — kept for older clients. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
titleFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string | null;
|
||||
|
||||
@@ -109,9 +109,15 @@ export class PortfoliosService {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
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(
|
||||
businessId,
|
||||
dto.slug ?? slugify(dto.title),
|
||||
dto.slug ?? slugify(titleEn || titleFa),
|
||||
);
|
||||
|
||||
const status = dto.status ?? ContentStatus.draft;
|
||||
@@ -139,7 +145,9 @@ export class PortfoliosService {
|
||||
const portfolio = await tx.portfolios.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
title: dto.title.trim(),
|
||||
title: titleFa,
|
||||
title_fa: titleFa,
|
||||
title_en: titleEn,
|
||||
slug,
|
||||
description: dto.abstract?.trim() || null,
|
||||
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
|
||||
@@ -198,12 +206,30 @@ export class PortfoliosService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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(
|
||||
businessId,
|
||||
slugify(dto.title),
|
||||
slugify(slugSource),
|
||||
portfolioId,
|
||||
);
|
||||
}
|
||||
@@ -245,23 +271,37 @@ export class PortfoliosService {
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: portfolioId },
|
||||
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,
|
||||
},
|
||||
data,
|
||||
include: portfolioInclude,
|
||||
});
|
||||
|
||||
@@ -540,12 +580,36 @@ export class PortfoliosService {
|
||||
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||
...(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) {
|
||||
const portfolio = await this.prisma.portfolios.findFirst({
|
||||
where: { id: portfolioId, business_id: businessId },
|
||||
@@ -619,7 +683,9 @@ export class PortfoliosService {
|
||||
return {
|
||||
id: portfolio.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,
|
||||
abstract: portfolio.description ?? '',
|
||||
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