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:
Alireza Hassani
2026-07-29 16:10:41 +03:30
co-authored by Cursor
parent 7244b70e90
commit 4598add88c
32 changed files with 4234 additions and 265 deletions
+1 -1
View File
@@ -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
View File
@@ -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;
+7
View File
@@ -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);
+7 -3
View File
@@ -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 |
+95
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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")
}
+2
View File
@@ -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,
+7 -7
View File
@@ -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(
+8 -2
View File
@@ -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 {}
+353 -21
View File
@@ -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
+480
View File
@@ -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,
+10
View File
@@ -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 {}
+71
View File
@@ -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 };
}
}
+3 -3
View File
@@ -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,
+22 -1
View File
@@ -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;
+87 -21
View File
@@ -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) ?? '',
+24
View File
@@ -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}`;
}