diff --git a/.cursor/rules/meshkee-project.mdc b/.cursor/rules/meshkee-project.mdc index 52dd837..a274407 100644 --- a/.cursor/rules/meshkee-project.mdc +++ b/.cursor/rules/meshkee-project.mdc @@ -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`. diff --git a/.env.example b/.env.example index cf5e620..11e3c76 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/database/migrations/042_business_old_business_id.sql b/database/migrations/042_business_old_business_id.sql new file mode 100644 index 0000000..4302a64 --- /dev/null +++ b/database/migrations/042_business_old_business_id.sql @@ -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; diff --git a/database/migrations/043_categories_old_id.sql b/database/migrations/043_categories_old_id.sql new file mode 100644 index 0000000..a56b9e6 --- /dev/null +++ b/database/migrations/043_categories_old_id.sql @@ -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; diff --git a/database/migrations/044_portfolios_media_old_id.sql b/database/migrations/044_portfolios_media_old_id.sql new file mode 100644 index 0000000..38cc0c8 --- /dev/null +++ b/database/migrations/044_portfolios_media_old_id.sql @@ -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; diff --git a/database/migrations/045_portfolios_title_fa_en.sql b/database/migrations/045_portfolios_title_fa_en.sql new file mode 100644 index 0000000..071cb88 --- /dev/null +++ b/database/migrations/045_portfolios_title_fa_en.sql @@ -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; diff --git a/database/migrations/046_blogs_old_id.sql b/database/migrations/046_blogs_old_id.sql new file mode 100644 index 0000000..3522eef --- /dev/null +++ b/database/migrations/046_blogs_old_id.sql @@ -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; diff --git a/database/migrations/047_customer_entity_users_old_id_blogs_unique.sql b/database/migrations/047_customer_entity_users_old_id_blogs_unique.sql new file mode 100644 index 0000000..f423a70 --- /dev/null +++ b/database/migrations/047_customer_entity_users_old_id_blogs_unique.sql @@ -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; diff --git a/database/migrations/048_portfolios_author_id.sql b/database/migrations/048_portfolios_author_id.sql new file mode 100644 index 0000000..a664356 --- /dev/null +++ b/database/migrations/048_portfolios_author_id.sql @@ -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); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9197132..2376573 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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 | diff --git a/package-lock.json b/package-lock.json index 34a910e..e0c9505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c0bc86b..64ac535 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 465ebe9..72966d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") -} diff --git a/src/app.module.ts b/src/app.module.ts index fd48db9..ead6248 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/blogs/blogs.service.ts b/src/blogs/blogs.service.ts index 92082d8..3d74a48 100644 --- a/src/blogs/blogs.service.ts +++ b/src/blogs/blogs.service.ts @@ -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, diff --git a/src/business-admin/business-admin.controller.ts b/src/business-admin/business-admin.controller.ts index c5fd911..6081585 100644 --- a/src/business-admin/business-admin.controller.ts +++ b/src/business-admin/business-admin.controller.ts @@ -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( diff --git a/src/business-admin/business-admin.module.ts b/src/business-admin/business-admin.module.ts index a3082dd..0a21651 100644 --- a/src/business-admin/business-admin.module.ts +++ b/src/business-admin/business-admin.module.ts @@ -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 {} - diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index a771912..507201f 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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) => ({ diff --git a/src/business-admin/dto/create-business.dto.ts b/src/business-admin/dto/create-business.dto.ts index 64227c5..42a7769 100644 --- a/src/business-admin/dto/create-business.dto.ts +++ b/src/business-admin/dto/create-business.dto.ts @@ -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; } diff --git a/src/business-admin/dto/migrate-from-old.dto.ts b/src/business-admin/dto/migrate-from-old.dto.ts new file mode 100644 index 0000000..1d2e783 --- /dev/null +++ b/src/business-admin/dto/migrate-from-old.dto.ts @@ -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[]; +} diff --git a/src/business-admin/dto/purge-business-data.dto.ts b/src/business-admin/dto/purge-business-data.dto.ts new file mode 100644 index 0000000..59df50e --- /dev/null +++ b/src/business-admin/dto/purge-business-data.dto.ts @@ -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[]; +} diff --git a/src/business-admin/dto/update-business.dto.ts b/src/business-admin/dto/update-business.dto.ts index bdabaa0..73fa7e2 100644 --- a/src/business-admin/dto/update-business.dto.ts +++ b/src/business-admin/dto/update-business.dto.ts @@ -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; } diff --git a/src/business-admin/legacy-migrate.service.ts b/src/business-admin/legacy-migrate.service.ts new file mode 100644 index 0000000..a3d7351 --- /dev/null +++ b/src/business-admin/legacy-migrate.service.ts @@ -0,0 +1,2441 @@ +import { Injectable } from '@nestjs/common'; +import { + ContentStatus, + BlogPostType, + MediaEntityType, + MediaType, + Prisma, +} from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { randomUUID } from 'crypto'; +import { RowDataPacket } from 'mysql2'; +import * as path from 'path'; +import sharp from 'sharp'; +import { LegacyMysqlService } from '../legacy-mysql/legacy-mysql.service'; +import { LegacySourceS3Service } from '../legacy-mysql/legacy-source-s3.service'; +import { businessMediaKey } from '../storage/storage-keys'; +import { StorageService } from '../storage/storage.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { MigrateFromOldEntity } from './dto/migrate-from-old.dto'; + +export type MigrateEntityCounts = { + created: number; + skipped: number; + total: number; + imagesCopied?: number; + imagesResized?: number; + imagesFailed?: number; + titlesUpdated?: number; + /** Customers/authors skipped because cell could not be normalized */ + skippedInvalidCell?: number; + /** Already linked to this business (idempotent re-run) */ + skippedAlreadyLinked?: number; + /** User row create failed (e.g. unique conflict) */ + skippedCreateFailed?: number; +}; + +export type MigrateEntityResult = + | MigrateEntityCounts + | { status: 'not_implemented' }; + +type OldPortfolioCategoryRow = RowDataPacket & { + id: number; + name: string; + name_en: string | null; + parent_id: number | null; + slug: string | null; + _lft: number; +}; + +type OldPortfolioRow = RowDataPacket & { + id: number; + name: string; + name_en: string | null; + url_title: string | null; + summary: string | null; + description: string | null; + url: string | null; + embed: string | null; + aspect_ratio: string | null; + portfolio_category_id: number | null; + user_id: number | null; + created_at: Date | null; + updated_at: Date | null; +}; + +type OldArticleRow = RowDataPacket & { + id: number; + title: string; + url_title: string | null; + abstract: string | null; + content: string | null; + verified: number | boolean | null; + publish_at: Date | null; + article_category_id: number | null; + user_id: number | null; + created_at: Date | null; + updated_at: Date | null; +}; + +type OldNewsRow = RowDataPacket & { + id: number; + title: string; + url_title: string | null; + summary: string | null; + content: string | null; + verified: number | boolean | null; + publish_at: Date | null; + news_category_id: number | null; + user_id: number | null; + created_at: Date | null; + updated_at: Date | null; +}; + +type OldAttributeRow = RowDataPacket & { + id: number; + name: string; + attribute_type: string; + option_type: string | null; + attributable_id: number; +}; + +type OldAttributeValueRow = RowDataPacket & { + id: number; + attribute_id: number; + name: string; + value: string | null; +}; + +type OldProductRow = RowDataPacket & { + id: number; + name: string; + name_en: string | null; + product_category_id: number | null; + description: string | null; + product_code: number | string | null; + brand_id: number | null; + created_at: Date | null; + updated_at: Date | null; +}; + +type OldUserRow = RowDataPacket & { + id: number; + name: string | null; + first_name_length: number | null; + name_en: string | null; + email: string | null; + cell_number: string | null; + password: string | null; + verified: number | boolean | null; + created_at: Date | null; + updated_at: Date | null; +}; + +type OldMediaRow = RowDataPacket & { + id: number; + model_id: number; + collection_name: string; + file_name: string; + mime_type: string | null; + size: number; + order_column: number | null; +}; + +type OldPivotRow = RowDataPacket & { + portfolio_id?: number; + article_id?: number; + category_id: number; +}; + +/** Byte-copy when ≤ this size; larger files are resized to MAX_IMAGE_EDGE. */ +const MAX_COPY_BYTES = 300 * 1024; +const MAX_IMAGE_EDGE = 1280; + +function slugify(value: string) { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'item' + ); +} + +function isTruthyFlag(value: number | boolean | null | undefined) { + return value === true || value === 1 || String(value) === '1'; +} + +/** Normalize legacy Iranian / E.164 cell numbers for Meshkee users.cell_number. */ +function normalizeLegacyCell(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + if (/^\+[1-9]\d{6,14}$/.test(trimmed)) return trimmed; + + const digits = trimmed.replace(/\D/g, ''); + if (!digits) return null; + if (digits.startsWith('98') && digits.length === 12) return `+${digits}`; + if (digits.startsWith('0') && digits.length === 11) { + return `+98${digits.slice(1)}`; + } + if (digits.length === 10 && digits.startsWith('9')) { + return `+98${digits}`; + } + if (/^[1-9]\d{6,14}$/.test(digits)) return `+${digits}`; + return null; +} + +/** Deterministic placeholder cell when legacy author has no usable phone. */ +function syntheticLegacyCell(oldUserId: number): string { + return `+999${String(oldUserId).padStart(10, '0')}`; +} + +function splitLegacyName( + name: string | null | undefined, + firstNameLength: number | null | undefined, +): { firstName: string; lastName: string } { + const full = (name ?? '').trim(); + if (!full) return { firstName: 'Customer', lastName: 'User' }; + + if ( + firstNameLength != null && + firstNameLength > 0 && + firstNameLength < full.length + ) { + const firstName = full.slice(0, firstNameLength).trim() || 'Customer'; + const lastName = full.slice(firstNameLength).trim() || firstName; + return { firstName, lastName }; + } + + const parts = full.split(/\s+/).filter(Boolean); + if (parts.length === 1) return { firstName: parts[0], lastName: parts[0] }; + return { + firstName: parts[0], + lastName: parts.slice(1).join(' '), + }; +} + +function normalizeLegacyPasswordHash(hash: string | null | undefined): string { + const value = (hash ?? '').trim(); + if (!value) { + return bcrypt.hashSync(`migrated-unusable-${randomUUID()}`, 10); + } + if (value.startsWith('$2y$')) return `$2a$${value.slice(4)}`; + return value; +} + +type ContentCategoryEntity = + | typeof MediaEntityType.portfolio + | typeof MediaEntityType.blog + | typeof MediaEntityType.customer + | typeof MediaEntityType.product; + +@Injectable() +export class LegacyMigrateService { + constructor( + private readonly prisma: PrismaService, + private readonly legacyMysql: LegacyMysqlService, + private readonly legacySourceS3: LegacySourceS3Service, + private readonly storage: StorageService, + ) {} + + async migrateEntities( + businessId: bigint, + oldBusinessId: bigint, + entities: MigrateFromOldEntity[], + ): Promise>> { + const results: Partial> = + {}; + + const selected = new Set(entities); + + // Items need category old→new map; run categories first when needed. + if ( + selected.has('portfolio') && + !selected.has('portfolio_categories') + ) { + results.portfolio_categories = await this.migrateNestedCategories( + businessId, + oldBusinessId, + { + entityType: MediaEntityType.portfolio, + table: 'portfolio_categories', + hasSlug: true, + }, + ); + } + if (selected.has('blog') && !selected.has('blog_categories')) { + results.blog_categories = await this.migrateNestedCategories( + businessId, + oldBusinessId, + { + entityType: MediaEntityType.blog, + table: 'article_categories', + hasSlug: true, + }, + ); + } + if (selected.has('customer') && !selected.has('customer_categories')) { + results.customer_categories = await this.migrateNestedCategories( + businessId, + oldBusinessId, + { + entityType: MediaEntityType.customer, + table: 'client_categories', + hasSlug: false, + }, + ); + } + if (selected.has('product') && !selected.has('product_categories')) { + try { + results.product_categories = await this.migrateProductCategories( + businessId, + oldBusinessId, + ); + } catch (err) { + console.error( + 'Legacy migrate: product_categories failed in pre-step', + err, + ); + results.product_categories = { status: 'not_implemented' }; + } + } + + for (const entity of entities) { + if (entity === 'portfolio_categories') { + if (!results.portfolio_categories) { + results[entity] = await this.migrateNestedCategories( + businessId, + oldBusinessId, + { + entityType: MediaEntityType.portfolio, + table: 'portfolio_categories', + hasSlug: true, + }, + ); + } + continue; + } + + if (entity === 'blog_categories') { + if (!results.blog_categories) { + results[entity] = await this.migrateNestedCategories( + businessId, + oldBusinessId, + { + entityType: MediaEntityType.blog, + table: 'article_categories', + hasSlug: true, + }, + ); + } + continue; + } + + if (entity === 'customer_categories') { + if (!results.customer_categories) { + results[entity] = await this.migrateNestedCategories( + businessId, + oldBusinessId, + { + entityType: MediaEntityType.customer, + table: 'client_categories', + hasSlug: false, + }, + ); + } + continue; + } + + if (entity === 'product_categories') { + if (!results.product_categories) { + try { + results[entity] = await this.migrateProductCategories( + businessId, + oldBusinessId, + ); + } catch (err) { + console.error('Legacy migrate: product_categories failed', err); + results[entity] = { status: 'not_implemented' }; + } + } + continue; + } + + if (entity === 'portfolio') { + results[entity] = await this.migratePortfolios( + businessId, + oldBusinessId, + ); + continue; + } + + if (entity === 'blog') { + results[entity] = await this.migrateBlogPosts(businessId, oldBusinessId); + continue; + } + + if (entity === 'customer') { + results[entity] = await this.migrateCustomers(businessId, oldBusinessId); + continue; + } + + if (entity === 'product') { + try { + // Ensure product categories exist (either selected explicitly or pulled above). + if (!results.product_categories) { + results.product_categories = await this.migrateProductCategories( + businessId, + oldBusinessId, + ); + } + + results[entity] = await this.migrateProducts( + businessId, + oldBusinessId, + ); + } catch (err) { + console.error('Legacy migrate: product failed', err); + results[entity] = { status: 'not_implemented' }; + } + continue; + } + } + + return results; + } + + private async migrateNestedCategories( + businessId: bigint, + oldBusinessId: bigint, + opts: { + entityType: ContentCategoryEntity; + table: + | 'portfolio_categories' + | 'article_categories' + | 'client_categories' + | 'product_categories'; + hasSlug: boolean; + }, + ): Promise { + const slugSelect = opts.hasSlug ? 'slug' : 'NULL AS slug'; + const rows = await this.legacyMysql.query( + `SELECT id, name, name_en, parent_id, ${slugSelect}, \`_lft\` AS _lft + FROM ${opts.table} + WHERE business_id = ? + ORDER BY \`_lft\` ASC`, + [Number(oldBusinessId)], + ); + + const sortOrderByOldId = this.siblingSortOrders(rows); + + const existing = await this.prisma.category.findMany({ + where: { + businessId, + entityType: opts.entityType, + oldId: { not: null }, + }, + select: { id: true, oldId: true }, + }); + + const oldToNew = new Map(); + for (const row of existing) { + if (row.oldId != null) { + oldToNew.set(Number(row.oldId), row.id); + } + } + + let created = 0; + let skipped = 0; + + for (const row of rows) { + if (oldToNew.has(row.id)) { + skipped += 1; + continue; + } + + const nameEn = (row.name_en ?? '').trim() || row.name.trim() || 'Category'; + const nameFa = row.name.trim() || null; + const rawSlug = (row.slug ?? '').trim(); + const baseSlug = this.normalizeSlug(rawSlug) || slugify(nameEn); + const slug = await this.ensureUniqueCategorySlug( + businessId, + opts.entityType, + baseSlug, + row.id, + ); + + const createdRow = await this.prisma.category.create({ + data: { + businessId, + entityType: opts.entityType, + parentId: null, + name: nameEn, + nameFa, + slug, + sortOrder: sortOrderByOldId.get(row.id) ?? 0, + oldId: BigInt(row.id), + isActive: true, + }, + }); + + oldToNew.set(row.id, createdRow.id); + created += 1; + } + + for (const row of rows) { + if (row.parent_id == null) continue; + const newId = oldToNew.get(row.id); + const newParentId = oldToNew.get(row.parent_id); + if (!newId || !newParentId) continue; + + await this.prisma.category.update({ + where: { id: newId }, + data: { parentId: newParentId }, + }); + } + + return { + created, + skipped, + total: rows.length, + }; + } + + private async migratePortfolios( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const bizRows = await this.legacyMysql.query( + `SELECT id, slug FROM businesses WHERE id = ? LIMIT 1`, + [oldBizId], + ); + const oldSlug = (bizRows[0]?.slug as string | undefined) ?? null; + + const portfolios = await this.legacyMysql.query( + `SELECT id, name, name_en, url_title, summary, description, url, embed, + aspect_ratio, portfolio_category_id, user_id, created_at, updated_at + FROM portfolios + WHERE business_id = ? AND deleted_at IS NULL + ORDER BY id ASC`, + [oldBizId], + ); + + const mediaRows = await this.legacyMysql.query( + `SELECT id, model_id, collection_name, file_name, mime_type, size, order_column + FROM media + WHERE model_type = 'portfolio' + AND model_id IN ( + SELECT id FROM portfolios WHERE business_id = ? AND deleted_at IS NULL + ) + ORDER BY model_id ASC, order_column ASC, id ASC`, + [oldBizId], + ); + + const pivots = await this.legacyMysql.query( + `SELECT portfolio_id, category_id + FROM portfolio_portfolio_category + WHERE portfolio_id IN ( + SELECT id FROM portfolios WHERE business_id = ? AND deleted_at IS NULL + )`, + [oldBizId], + ); + + const mediaByPortfolio = new Map(); + for (const row of mediaRows) { + const list = mediaByPortfolio.get(row.model_id) ?? []; + list.push(row); + mediaByPortfolio.set(row.model_id, list); + } + + const categoriesByPortfolio = new Map(); + for (const row of pivots) { + if (row.portfolio_id == null) continue; + const list = categoriesByPortfolio.get(row.portfolio_id) ?? []; + list.push(row.category_id); + categoriesByPortfolio.set(row.portfolio_id, list); + } + + const categoryMap = await this.loadCategoryOldIdMap( + businessId, + MediaEntityType.portfolio, + ); + const existingPortfolios = await this.prisma.portfolios.findMany({ + where: { business_id: businessId, old_id: { not: null } }, + select: { + id: true, + old_id: true, + title_fa: true, + title_en: true, + title: true, + author_id: true, + }, + }); + const existingByOldId = new Map( + existingPortfolios + .filter((p) => p.old_id != null) + .map((p) => [ + Number(p.old_id), + { + id: p.id, + titleFa: p.title_fa, + titleEn: p.title_en, + title: p.title, + authorId: p.author_id, + }, + ]), + ); + const existingPortfolioOldIds = new Set(existingByOldId.keys()); + + const existingMedia = await this.prisma.media.findMany({ + where: { businessId, oldId: { not: null } }, + select: { id: true, oldId: true }, + }); + const mediaOldToNew = new Map(); + for (const row of existingMedia) { + if (row.oldId != null) { + mediaOldToNew.set(Number(row.oldId), row.id); + } + } + + const probeMedia = mediaRows + .filter((m) => Number(m.size) > 0) + .slice(0, 12) + .map((m) => ({ id: m.id, fileName: m.file_name })); + const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( + oldBizId, + oldSlug, + probeMedia, + ); + + let created = 0; + let skipped = 0; + let titlesUpdated = 0; + let imagesCopied = 0; + let imagesResized = 0; + let imagesFailed = 0; + const authorCache = new Map(); + + for (const [index, row] of portfolios.entries()) { + const titleFa = + (row.name ?? '').trim() || row.name_en?.trim() || 'Portfolio'; + const titleEn = row.name_en?.trim() || null; + const authorId = await this.resolveLegacyAuthorId(row.user_id, authorCache); + + const existing = existingByOldId.get(row.id); + if (existing) { + skipped += 1; + const needsTitleFa = !(existing.titleFa ?? '').trim(); + const needsTitleEn = + titleEn != null && !(existing.titleEn ?? '').trim(); + const needsAuthor = existing.authorId == null && authorId != null; + if (needsTitleFa || needsTitleEn || needsAuthor) { + await this.prisma.portfolios.update({ + where: { id: existing.id }, + data: { + ...(needsTitleFa + ? { title: titleFa, title_fa: titleFa } + : {}), + ...(needsTitleEn ? { title_en: titleEn } : {}), + ...(needsAuthor ? { author_id: authorId } : {}), + }, + }); + titlesUpdated += 1; + } + continue; + } + + const baseSlug = + this.normalizeSlug((row.url_title ?? '').trim()) || + slugify(titleEn || titleFa); + const slug = await this.ensureUniquePortfolioSlug( + businessId, + baseSlug, + row.id, + ); + + const portfolioMedia = mediaByPortfolio.get(row.id) ?? []; + let featuredMediaId: bigint | null = null; + const galleryMediaIds: bigint[] = []; + + for (const media of portfolioMedia) { + const size = Number(media.size) || 0; + if (size <= 0) { + imagesFailed += 1; + continue; + } + + let newMediaId = mediaOldToNew.get(media.id) ?? null; + if (!newMediaId) { + try { + const copied = await this.copyLegacyMedia({ + businessId, + oldMedia: media, + storagePrefix, + }); + newMediaId = copied.id; + mediaOldToNew.set(media.id, newMediaId); + imagesCopied += 1; + if (copied.resized) { + imagesResized += 1; + } + } catch { + imagesFailed += 1; + continue; + } + } + + if (media.collection_name === 'main_image' && !featuredMediaId) { + featuredMediaId = newMediaId; + } else if (media.collection_name === 'images') { + galleryMediaIds.push(newMediaId); + } else if ( + media.collection_name === 'main_image' && + featuredMediaId && + featuredMediaId !== newMediaId + ) { + galleryMediaIds.push(newMediaId); + } + } + + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + + const portfolio = await this.prisma.portfolios.create({ + data: { + business_id: businessId, + author_id: authorId, + title: titleFa, + title_fa: titleFa, + title_en: titleEn, + slug, + description: row.summary?.trim() || null, + content: { + html: row.description ?? '', + embed: row.embed ?? null, + } as Prisma.InputJsonValue, + project_url: row.url?.trim() || null, + status: ContentStatus.published, + featured_media_id: featuredMediaId, + sort_order: index, + published_at: createdAt, + metadata: { + aspectRatio: row.aspect_ratio ?? null, + migratedFromOldId: row.id, + } as Prisma.InputJsonValue, + old_id: BigInt(row.id), + created_at: createdAt, + updated_at: row.updated_at ? new Date(row.updated_at) : createdAt, + }, + }); + + const oldCategoryIds = new Set( + categoriesByPortfolio.get(row.id) ?? [], + ); + if (row.portfolio_category_id != null) { + oldCategoryIds.add(row.portfolio_category_id); + } + + for (const oldCategoryId of oldCategoryIds) { + const newCategoryId = categoryMap.get(oldCategoryId); + if (!newCategoryId) continue; + try { + await this.prisma.categoryAssignment.create({ + data: { + businessId, + categoryId: newCategoryId, + entityType: MediaEntityType.portfolio, + entityId: portfolio.id, + }, + }); + } catch { + // ignore duplicate assignment + } + } + + for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) { + await this.prisma.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.portfolio, + entityId: portfolio.id, + sortOrder: galleryIndex, + isFeatured: false, + }, + }); + } + + created += 1; + } + + return { + created, + skipped, + total: portfolios.length, + imagesCopied, + imagesResized, + imagesFailed, + titlesUpdated, + }; + } + + private async migrateBlogPosts( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const articles = await this.migrateArticles(businessId, oldBusinessId); + const news = await this.migrateNews(businessId, oldBusinessId); + return { + created: articles.created + news.created, + skipped: articles.skipped + news.skipped, + total: articles.total + news.total, + imagesCopied: + (articles.imagesCopied ?? 0) + (news.imagesCopied ?? 0), + imagesResized: + (articles.imagesResized ?? 0) + (news.imagesResized ?? 0), + imagesFailed: + (articles.imagesFailed ?? 0) + (news.imagesFailed ?? 0), + }; + } + + private async migrateArticles( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const bizRows = await this.legacyMysql.query( + `SELECT id, slug FROM businesses WHERE id = ? LIMIT 1`, + [oldBizId], + ); + const oldSlug = (bizRows[0]?.slug as string | undefined) ?? null; + + const articles = await this.legacyMysql.query( + `SELECT id, title, url_title, abstract, content, verified, publish_at, + article_category_id, user_id, created_at, updated_at + FROM articles + WHERE business_id = ? + ORDER BY id ASC`, + [oldBizId], + ); + + const mediaRows = await this.legacyMysql.query( + `SELECT id, model_id, collection_name, file_name, mime_type, size, order_column + FROM media + WHERE model_type = 'article' + AND model_id IN ( + SELECT id FROM articles WHERE business_id = ? + ) + ORDER BY model_id ASC, order_column ASC, id ASC`, + [oldBizId], + ); + + const pivots = await this.legacyMysql.query< + (RowDataPacket & { + article_id: number; + article_category_id: number; + })[] + >( + `SELECT article_id, article_category_id + FROM article_article_category + WHERE article_id IN ( + SELECT id FROM articles WHERE business_id = ? + )`, + [oldBizId], + ); + + const mediaByArticle = new Map(); + for (const row of mediaRows) { + const list = mediaByArticle.get(row.model_id) ?? []; + list.push(row); + mediaByArticle.set(row.model_id, list); + } + + const categoriesByArticle = new Map(); + for (const row of pivots) { + const list = categoriesByArticle.get(row.article_id) ?? []; + list.push(row.article_category_id); + categoriesByArticle.set(row.article_id, list); + } + + const categoryMap = await this.loadCategoryOldIdMap( + businessId, + MediaEntityType.blog, + ); + const existingBlogs = await this.prisma.blogs.findMany({ + where: { + business_id: businessId, + post_type: BlogPostType.blog, + old_id: { not: null }, + }, + select: { id: true, old_id: true, author_id: true }, + }); + const existingByOldId = new Map( + existingBlogs + .filter((b) => b.old_id != null) + .map((b) => [ + Number(b.old_id), + { id: b.id, authorId: b.author_id }, + ]), + ); + + const existingMedia = await this.prisma.media.findMany({ + where: { businessId, oldId: { not: null } }, + select: { id: true, oldId: true }, + }); + const mediaOldToNew = new Map(); + for (const row of existingMedia) { + if (row.oldId != null) { + mediaOldToNew.set(Number(row.oldId), row.id); + } + } + + const probeMedia = mediaRows + .filter((m) => Number(m.size) > 0) + .slice(0, 12) + .map((m) => ({ id: m.id, fileName: m.file_name })); + const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( + oldBizId, + oldSlug, + probeMedia, + ); + + let created = 0; + let skipped = 0; + let imagesCopied = 0; + let imagesResized = 0; + let imagesFailed = 0; + const authorCache = new Map(); + + for (const row of articles) { + const authorId = await this.resolveLegacyAuthorId(row.user_id, authorCache); + const existing = existingByOldId.get(row.id); + if (existing) { + skipped += 1; + if (existing.authorId == null && authorId != null) { + await this.prisma.blogs.update({ + where: { id: existing.id }, + data: { author_id: authorId }, + }); + } + continue; + } + + const title = (row.title ?? '').trim() || 'Blog'; + const baseSlug = + this.normalizeSlug((row.url_title ?? '').trim()) || slugify(title); + const slug = await this.ensureUniqueBlogSlug(businessId, baseSlug, row.id); + + const articleMedia = mediaByArticle.get(row.id) ?? []; + let featuredMediaId: bigint | null = null; + const galleryMediaIds: bigint[] = []; + + for (const media of articleMedia) { + const size = Number(media.size) || 0; + if (size <= 0) { + imagesFailed += 1; + continue; + } + + let newMediaId = mediaOldToNew.get(media.id) ?? null; + if (!newMediaId) { + try { + const copied = await this.copyLegacyMedia({ + businessId, + oldMedia: media, + storagePrefix, + }); + newMediaId = copied.id; + mediaOldToNew.set(media.id, newMediaId); + imagesCopied += 1; + if (copied.resized) { + imagesResized += 1; + } + } catch { + imagesFailed += 1; + continue; + } + } + + if (media.collection_name === 'main_image' && !featuredMediaId) { + featuredMediaId = newMediaId; + } else if (media.collection_name === 'images') { + galleryMediaIds.push(newMediaId); + } else if ( + media.collection_name === 'main_image' && + featuredMediaId && + featuredMediaId !== newMediaId + ) { + galleryMediaIds.push(newMediaId); + } + } + + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + const publishedAt = row.publish_at + ? new Date(row.publish_at) + : createdAt; + const isVerified = isTruthyFlag(row.verified); + + const blog = await this.prisma.blogs.create({ + data: { + business_id: businessId, + author_id: authorId, + title, + slug, + excerpt: row.abstract?.trim() || null, + content: { + html: row.content ?? '', + } as Prisma.InputJsonValue, + status: isVerified ? ContentStatus.published : ContentStatus.draft, + featured_media_id: featuredMediaId, + published_at: isVerified ? publishedAt : null, + post_type: BlogPostType.blog, + metadata: { + migratedFromOldId: row.id, + legacyTable: 'articles', + verified: isVerified, + } as Prisma.InputJsonValue, + old_id: BigInt(row.id), + created_at: createdAt, + updated_at: row.updated_at ? new Date(row.updated_at) : createdAt, + }, + }); + + const oldCategoryIds = new Set( + categoriesByArticle.get(row.id) ?? [], + ); + if (row.article_category_id != null) { + oldCategoryIds.add(row.article_category_id); + } + + for (const oldCategoryId of oldCategoryIds) { + const newCategoryId = categoryMap.get(oldCategoryId); + if (!newCategoryId) continue; + try { + await this.prisma.categoryAssignment.create({ + data: { + businessId, + categoryId: newCategoryId, + entityType: MediaEntityType.blog, + entityId: blog.id, + }, + }); + } catch { + // ignore duplicate assignment + } + } + + for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) { + await this.prisma.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.blog, + entityId: blog.id, + sortOrder: galleryIndex, + isFeatured: false, + }, + }); + } + + created += 1; + } + + return { + created, + skipped, + total: articles.length, + imagesCopied, + imagesResized, + imagesFailed, + }; + } + + private async migrateNews( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const bizRows = await this.legacyMysql.query( + `SELECT id, slug FROM businesses WHERE id = ? LIMIT 1`, + [oldBizId], + ); + const oldSlug = (bizRows[0]?.slug as string | undefined) ?? null; + + const newsRows = await this.legacyMysql.query( + `SELECT id, title, url_title, summary, content, verified, publish_at, + news_category_id, user_id, created_at, updated_at + FROM news + WHERE business_id = ? + ORDER BY id ASC`, + [oldBizId], + ); + + const mediaRows = await this.legacyMysql.query( + `SELECT id, model_id, collection_name, file_name, mime_type, size, order_column + FROM media + WHERE model_type = 'news' + AND model_id IN ( + SELECT id FROM news WHERE business_id = ? + ) + ORDER BY model_id ASC, order_column ASC, id ASC`, + [oldBizId], + ); + + const mediaByNews = new Map(); + for (const row of mediaRows) { + const list = mediaByNews.get(row.model_id) ?? []; + list.push(row); + mediaByNews.set(row.model_id, list); + } + + const newsCategoryId = newsRows.length + ? await this.ensureNewsCategory(businessId) + : null; + + const existingNews = await this.prisma.blogs.findMany({ + where: { + business_id: businessId, + post_type: BlogPostType.news, + old_id: { not: null }, + }, + select: { id: true, old_id: true, author_id: true }, + }); + const existingByOldId = new Map( + existingNews + .filter((b) => b.old_id != null) + .map((b) => [ + Number(b.old_id), + { id: b.id, authorId: b.author_id }, + ]), + ); + + const existingMedia = await this.prisma.media.findMany({ + where: { businessId, oldId: { not: null } }, + select: { id: true, oldId: true }, + }); + const mediaOldToNew = new Map(); + for (const row of existingMedia) { + if (row.oldId != null) { + mediaOldToNew.set(Number(row.oldId), row.id); + } + } + + const probeMedia = mediaRows + .filter((m) => Number(m.size) > 0) + .slice(0, 12) + .map((m) => ({ id: m.id, fileName: m.file_name })); + const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( + oldBizId, + oldSlug, + probeMedia, + ); + + let created = 0; + let skipped = 0; + let imagesCopied = 0; + let imagesResized = 0; + let imagesFailed = 0; + const authorCache = new Map(); + + for (const row of newsRows) { + const authorId = await this.resolveLegacyAuthorId(row.user_id, authorCache); + const existing = existingByOldId.get(row.id); + if (existing) { + skipped += 1; + if (existing.authorId == null && authorId != null) { + await this.prisma.blogs.update({ + where: { id: existing.id }, + data: { author_id: authorId }, + }); + } + continue; + } + + const title = (row.title ?? '').trim() || 'News'; + const baseSlug = + this.normalizeSlug((row.url_title ?? '').trim()) || slugify(title); + const slug = await this.ensureUniqueBlogSlug(businessId, baseSlug, row.id); + + const itemMedia = mediaByNews.get(row.id) ?? []; + let featuredMediaId: bigint | null = null; + + for (const media of itemMedia) { + const size = Number(media.size) || 0; + if (size <= 0) { + imagesFailed += 1; + continue; + } + + let newMediaId = mediaOldToNew.get(media.id) ?? null; + if (!newMediaId) { + try { + const copied = await this.copyLegacyMedia({ + businessId, + oldMedia: media, + storagePrefix, + }); + newMediaId = copied.id; + mediaOldToNew.set(media.id, newMediaId); + imagesCopied += 1; + if (copied.resized) { + imagesResized += 1; + } + } catch { + imagesFailed += 1; + continue; + } + } + + if ( + (media.collection_name === 'main_image' || + media.collection_name === 'images') && + !featuredMediaId + ) { + featuredMediaId = newMediaId; + } + } + + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + const publishedAt = row.publish_at + ? new Date(row.publish_at) + : createdAt; + const isVerified = isTruthyFlag(row.verified); + + const blog = await this.prisma.blogs.create({ + data: { + business_id: businessId, + author_id: authorId, + title, + slug, + excerpt: row.summary?.trim() || null, + content: { + html: row.content ?? '', + } as Prisma.InputJsonValue, + status: isVerified ? ContentStatus.published : ContentStatus.draft, + featured_media_id: featuredMediaId, + published_at: isVerified ? publishedAt : null, + post_type: BlogPostType.news, + metadata: { + migratedFromOldId: row.id, + legacyTable: 'news', + verified: isVerified, + } as Prisma.InputJsonValue, + old_id: BigInt(row.id), + created_at: createdAt, + updated_at: row.updated_at ? new Date(row.updated_at) : createdAt, + }, + }); + + if (newsCategoryId) { + try { + await this.prisma.categoryAssignment.create({ + data: { + businessId, + categoryId: newsCategoryId, + entityType: MediaEntityType.blog, + entityId: blog.id, + }, + }); + } catch { + // ignore duplicate assignment + } + } + + created += 1; + } + + return { + created, + skipped, + total: newsRows.length, + imagesCopied, + imagesResized, + imagesFailed, + }; + } + + private async ensureNewsCategory(businessId: bigint): Promise { + const existing = await this.prisma.category.findFirst({ + where: { + businessId, + entityType: MediaEntityType.blog, + OR: [{ slug: 'news' }, { name: 'News' }, { nameFa: 'News' }], + }, + select: { id: true }, + }); + if (existing) return existing.id; + + const slug = await this.ensureUniqueCategorySlug( + businessId, + MediaEntityType.blog, + 'news', + 0, + ); + + const created = await this.prisma.category.create({ + data: { + businessId, + entityType: MediaEntityType.blog, + parentId: null, + name: 'News', + nameFa: 'News', + slug, + sortOrder: 0, + isActive: true, + }, + }); + return created.id; + } + + private async migrateCustomers( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const users = await this.legacyMysql.query( + `SELECT u.id, u.name, u.first_name_length, u.name_en, u.email, u.cell_number, + u.password, u.verified, u.created_at, u.updated_at + FROM users u + INNER JOIN business_user bu + ON bu.user_id = u.id + AND bu.business_id = ? + AND bu.responsibility = 'client' + WHERE u.deleted_at IS NULL + ORDER BY u.id ASC`, + [oldBizId], + ); + + const pivots = await this.legacyMysql.query< + (RowDataPacket & { + client_id: number; + client_category_id: number; + })[] + >( + `SELECT ccc.client_id, ccc.client_category_id + FROM client_client_category ccc + INNER JOIN business_user bu + ON bu.user_id = ccc.client_id + AND bu.business_id = ? + AND bu.responsibility = 'client'`, + [oldBizId], + ); + + const categoriesByUser = new Map(); + for (const row of pivots) { + const list = categoriesByUser.get(row.client_id) ?? []; + list.push(row.client_category_id); + categoriesByUser.set(row.client_id, list); + } + + const categoryMap = await this.loadCategoryOldIdMap( + businessId, + MediaEntityType.customer, + ); + + const customerRole = await this.prisma.role.findUnique({ + where: { slug: 'customer' }, + select: { id: true }, + }); + if (!customerRole) { + throw new Error('Customer role not found'); + } + + const existingByOldId = await this.prisma.user.findMany({ + where: { oldId: { not: null } }, + select: { id: true, oldId: true }, + }); + const userByOldId = new Map( + existingByOldId + .filter((u) => u.oldId != null) + .map((u) => [Number(u.oldId), u.id]), + ); + + let created = 0; + let skipped = 0; + let skippedInvalidCell = 0; + let skippedAlreadyLinked = 0; + let skippedCreateFailed = 0; + + for (const row of users) { + const cellNumber = normalizeLegacyCell(row.cell_number); + if (!cellNumber) { + skipped += 1; + skippedInvalidCell += 1; + continue; + } + + const { firstName, lastName } = splitLegacyName( + row.name, + row.first_name_length, + ); + const email = row.email?.trim() || null; + const passwordHash = normalizeLegacyPasswordHash(row.password); + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + const verifiedAt = isTruthyFlag(row.verified) ? createdAt : null; + + let userId = userByOldId.get(row.id) ?? null; + + if (!userId) { + const byCell = await this.prisma.user.findUnique({ + where: { cellNumber }, + select: { id: true, oldId: true }, + }); + if (byCell) { + userId = byCell.id; + if (byCell.oldId == null) { + await this.prisma.user.update({ + where: { id: byCell.id }, + data: { oldId: BigInt(row.id) }, + }); + } + userByOldId.set(row.id, userId); + } + } + + if (!userId) { + try { + const createdUser = await this.prisma.user.create({ + data: { + cellNumber, + passwordHash, + email, + firstName, + lastName, + isActive: true, + cellVerifiedAt: verifiedAt, + oldId: BigInt(row.id), + createdAt, + updatedAt: row.updated_at ? new Date(row.updated_at) : createdAt, + profile: { + migratedFromOldId: row.id, + nameEn: row.name_en?.trim() || null, + } as Prisma.InputJsonValue, + }, + }); + userId = createdUser.id; + userByOldId.set(row.id, userId); + } catch { + skipped += 1; + skippedCreateFailed += 1; + continue; + } + } + + const existingLink = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + select: { id: true }, + }); + + if (existingLink) { + skipped += 1; + skippedAlreadyLinked += 1; + } else { + await this.prisma.businessCustomer.create({ + data: { + businessId, + userId, + createdAt, + isEnabled: true, + }, + }); + created += 1; + } + + const hasCustomerRole = await this.prisma.userRole.findUnique({ + where: { + userId_roleId: { + userId, + roleId: customerRole.id, + }, + }, + select: { id: true }, + }); + if (!hasCustomerRole) { + await this.prisma.userRole.create({ + data: { + userId, + roleId: customerRole.id, + }, + }); + } + + const oldCategoryIds = categoriesByUser.get(row.id) ?? []; + for (const oldCategoryId of oldCategoryIds) { + const newCategoryId = categoryMap.get(oldCategoryId); + if (!newCategoryId) continue; + try { + await this.prisma.categoryAssignment.create({ + data: { + businessId, + categoryId: newCategoryId, + entityType: MediaEntityType.customer, + entityId: userId, + }, + }); + } catch { + // ignore duplicate assignment + } + } + } + + return { + created, + skipped, + total: users.length, + skippedInvalidCell, + skippedAlreadyLinked, + skippedCreateFailed, + }; + } + + private async copyLegacyMedia(input: { + businessId: bigint; + oldMedia: OldMediaRow; + storagePrefix: string; + }): Promise<{ id: bigint; resized: boolean }> { + const { businessId, oldMedia, storagePrefix } = input; + const key = this.legacySourceS3.spatieObjectKey( + storagePrefix, + oldMedia.id, + oldMedia.file_name, + ); + const source = await this.legacySourceS3.getObjectBuffer(key); + const sourceMime = (oldMedia.mime_type ?? 'image/jpeg') + .split(';')[0] + .trim() + .toLowerCase(); + + const needsResize = source.length > MAX_COPY_BYTES; + let uploadBody = source; + let mimeType = sourceMime || 'image/jpeg'; + let width: number | null = null; + let height: number | null = null; + let resized = false; + + if (needsResize) { + const prepared = await this.downscaleLegacyImage(source, sourceMime); + uploadBody = prepared.body; + mimeType = prepared.mimeType; + width = prepared.width; + height = prepared.height; + resized = true; + } else { + try { + const meta = await sharp(source, { failOn: 'none' }).metadata(); + width = meta.width ?? null; + height = meta.height ?? null; + } catch { + // keep null + } + } + + if (!width || !height) { + throw new Error(`Could not read image dimensions for media ${oldMedia.id}`); + } + + const ext = + path.extname(oldMedia.file_name).toLowerCase() || + this.extensionFromMime(mimeType); + const outExt = resized ? this.extensionFromMime(mimeType) : ext; + const fileName = `${randomUUID()}${outExt}`; + const storageKey = businessMediaKey(businessId, fileName); + + const stored = await this.storage.upload({ + key: storageKey, + body: uploadBody, + contentType: mimeType || 'image/jpeg', + }); + + const created = await this.prisma.media.create({ + data: { + businessId, + mediaType: MediaType.image, + storageDisk: stored.storageDisk, + storagePath: stored.storagePath, + publicUrl: stored.publicUrl, + fileName, + originalFileName: oldMedia.file_name, + mimeType: mimeType || 'image/jpeg', + fileSizeBytes: BigInt(uploadBody.length), + width, + height, + oldId: BigInt(oldMedia.id), + metadata: { + migratedFromOldMediaId: oldMedia.id, + collection: oldMedia.collection_name, + resizedFromBytes: resized ? source.length : undefined, + maxEdge: resized ? MAX_IMAGE_EDGE : undefined, + } as Prisma.InputJsonValue, + }, + }); + + return { id: created.id, resized }; + } + + /** Fit inside 1280×1280 and recompress oversized legacy images. */ + private async downscaleLegacyImage( + source: Buffer, + sourceMime: string, + ): Promise<{ + body: Buffer; + mimeType: string; + width: number; + height: number; + }> { + const pipeline = sharp(source, { failOn: 'none' }) + .rotate() + .resize(MAX_IMAGE_EDGE, MAX_IMAGE_EDGE, { + fit: 'inside', + withoutEnlargement: true, + }); + + const keepPng = sourceMime.includes('png'); + const keepWebp = sourceMime.includes('webp'); + + let body: Buffer; + let mimeType: string; + let info: sharp.OutputInfo; + + if (keepPng) { + const out = await pipeline + .png({ compressionLevel: 8 }) + .toBuffer({ resolveWithObject: true }); + body = out.data; + info = out.info; + mimeType = 'image/png'; + } else if (keepWebp) { + const out = await pipeline + .webp({ quality: 80 }) + .toBuffer({ resolveWithObject: true }); + body = out.data; + info = out.info; + mimeType = 'image/webp'; + } else { + const out = await pipeline + .jpeg({ quality: 80, mozjpeg: true }) + .toBuffer({ resolveWithObject: true }); + body = out.data; + info = out.info; + mimeType = 'image/jpeg'; + } + + if (!info.width || !info.height) { + throw new Error('Downscaled image missing dimensions'); + } + + return { + body, + mimeType, + width: info.width, + height: info.height, + }; + } + + private async loadCategoryOldIdMap( + businessId: bigint, + entityType: ContentCategoryEntity, + ): Promise> { + const rows = await this.prisma.category.findMany({ + where: { + businessId, + entityType, + oldId: { not: null }, + }, + select: { id: true, oldId: true }, + }); + const map = new Map(); + for (const row of rows) { + if (row.oldId != null) { + map.set(Number(row.oldId), row.id); + } + } + return map; + } + + private async resolveLegacyAuthorId( + oldUserId: number | null | undefined, + cache: Map, + ): Promise { + if (oldUserId == null || !Number.isFinite(oldUserId) || oldUserId <= 0) { + return null; + } + if (cache.has(oldUserId)) { + return cache.get(oldUserId) ?? null; + } + const userId = await this.ensureLegacyUser(oldUserId, { + allowSyntheticCell: true, + }); + cache.set(oldUserId, userId); + return userId; + } + + /** + * Find-or-create a Meshkee user from a legacy WillaEngine users row. + * Authors without a usable phone get a synthetic +999… cell so the FK can be set. + */ + private async ensureLegacyUser( + oldUserId: number, + opts: { allowSyntheticCell: boolean }, + ): Promise { + const existing = await this.prisma.user.findFirst({ + where: { oldId: BigInt(oldUserId) }, + select: { id: true }, + }); + if (existing) return existing.id; + + const rows = await this.legacyMysql.query( + `SELECT id, name, first_name_length, name_en, email, cell_number, + password, verified, created_at, updated_at + FROM users + WHERE id = ? + LIMIT 1`, + [oldUserId], + ); + const row = rows[0]; + if (!row) return null; + + let cellNumber = normalizeLegacyCell(row.cell_number); + if (!cellNumber) { + if (!opts.allowSyntheticCell) return null; + cellNumber = syntheticLegacyCell(oldUserId); + } + + const byCell = await this.prisma.user.findUnique({ + where: { cellNumber }, + select: { id: true, oldId: true }, + }); + if (byCell) { + if (byCell.oldId == null) { + await this.prisma.user.update({ + where: { id: byCell.id }, + data: { oldId: BigInt(oldUserId) }, + }); + } + return byCell.id; + } + + const { firstName, lastName } = splitLegacyName( + row.name, + row.first_name_length, + ); + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + + try { + const created = await this.prisma.user.create({ + data: { + cellNumber, + passwordHash: normalizeLegacyPasswordHash(row.password), + email: row.email?.trim() || null, + firstName, + lastName, + isActive: true, + cellVerifiedAt: isTruthyFlag(row.verified) ? createdAt : null, + oldId: BigInt(oldUserId), + createdAt, + updatedAt: row.updated_at ? new Date(row.updated_at) : createdAt, + profile: { + migratedFromOldId: oldUserId, + nameEn: row.name_en?.trim() || null, + syntheticCell: cellNumber.startsWith('+999'), + roleHint: 'legacy_author', + } as Prisma.InputJsonValue, + }, + }); + return created.id; + } catch { + const again = await this.prisma.user.findFirst({ + where: { + OR: [{ oldId: BigInt(oldUserId) }, { cellNumber }], + }, + select: { id: true }, + }); + return again?.id ?? null; + } + } + + private siblingSortOrders( + rows: OldPortfolioCategoryRow[], + ): Map { + const counters = new Map(); + const result = new Map(); + + for (const row of rows) { + const parentKey = row.parent_id == null ? 'root' : String(row.parent_id); + const sortOrder = counters.get(parentKey) ?? 0; + counters.set(parentKey, sortOrder + 1); + result.set(row.id, sortOrder); + } + + return result; + } + + private normalizeSlug(value: string): string { + if (!value) return ''; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) { + return slugify(value); + } + return value; + } + + private extensionFromMime(mimeType: string) { + switch (mimeType) { + case 'image/png': + return '.png'; + case 'image/webp': + return '.webp'; + case 'image/gif': + return '.gif'; + default: + return '.jpg'; + } + } + + private async ensureUniqueCategorySlug( + businessId: bigint, + entityType: ContentCategoryEntity, + baseSlug: string, + oldId: number, + ): Promise { + let slug = baseSlug || `category-old-${oldId}`; + const clash = await this.prisma.category.findFirst({ + where: { + businessId, + entityType, + slug, + }, + select: { id: true }, + }); + if (!clash) return slug; + + slug = `${baseSlug}-old-${oldId}`.replace(/-+/g, '-'); + const stillClash = await this.prisma.category.findFirst({ + where: { + businessId, + entityType, + slug, + }, + select: { id: true }, + }); + if (!stillClash) return slug; + return `${slug}-${Date.now()}`; + } + + private async ensureUniquePortfolioSlug( + businessId: bigint, + baseSlug: string, + oldId: number, + ): Promise { + let slug = baseSlug || `portfolio-old-${oldId}`; + const clash = await this.prisma.portfolios.findFirst({ + where: { business_id: businessId, slug }, + select: { id: true }, + }); + if (!clash) return slug; + + slug = `${baseSlug}-old-${oldId}`.replace(/-+/g, '-'); + const stillClash = await this.prisma.portfolios.findFirst({ + where: { business_id: businessId, slug }, + select: { id: true }, + }); + if (!stillClash) return slug; + return `${slug}-${Date.now()}`; + } + + private async ensureUniqueBlogSlug( + businessId: bigint, + baseSlug: string, + oldId: number, + ): Promise { + let slug = baseSlug || `blog-old-${oldId}`; + const clash = await this.prisma.blogs.findFirst({ + where: { business_id: businessId, slug }, + select: { id: true }, + }); + if (!clash) return slug; + + slug = `${baseSlug}-old-${oldId}`.replace(/-+/g, '-'); + const stillClash = await this.prisma.blogs.findFirst({ + where: { business_id: businessId, slug }, + select: { id: true }, + }); + if (!stillClash) return slug; + return `${slug}-${Date.now()}`; + } + + private async migrateProducts( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const categoryMap = await this.loadCategoryOldIdMap( + businessId, + MediaEntityType.product, + ); + + const bizRows = await this.legacyMysql.query( + `SELECT id, slug FROM businesses WHERE id = ? LIMIT 1`, + [oldBizId], + ); + const oldSlug = (bizRows[0]?.slug as string | undefined) ?? null; + + const products = await this.legacyMysql.query( + `SELECT id, name, name_en, product_category_id, description, product_code, + brand_id, created_at, updated_at + FROM products + WHERE business_id = ? AND deleted_at IS NULL + ORDER BY id ASC`, + [oldBizId], + ); + + const pivots = await this.legacyMysql.query< + (RowDataPacket & { + product_id: number; + product_category_id: number; + })[] + >( + `SELECT product_id, product_category_id + FROM product_product_category + WHERE product_id IN ( + SELECT id FROM products WHERE business_id = ? AND deleted_at IS NULL + )`, + [oldBizId], + ); + + const categoriesByProduct = new Map(); + for (const row of pivots) { + const list = categoriesByProduct.get(row.product_id) ?? []; + list.push(row.product_category_id); + categoriesByProduct.set(row.product_id, list); + } + + const mediaRows = await this.legacyMysql.query( + `SELECT id, model_id, collection_name, file_name, mime_type, size, order_column + FROM media + WHERE model_type = 'product' + AND model_id IN ( + SELECT id FROM products WHERE business_id = ? AND deleted_at IS NULL + ) + ORDER BY model_id ASC, order_column ASC, id ASC`, + [oldBizId], + ); + + const mediaByProduct = new Map(); + for (const row of mediaRows) { + const list = mediaByProduct.get(row.model_id) ?? []; + list.push(row); + mediaByProduct.set(row.model_id, list); + } + + const existingMedia = await this.prisma.media.findMany({ + where: { businessId, oldId: { not: null } }, + select: { id: true, oldId: true }, + }); + const mediaOldToNew = new Map(); + for (const row of existingMedia) { + if (row.oldId != null) { + mediaOldToNew.set(Number(row.oldId), row.id); + } + } + + // Idempotency via stable slug containing legacy id. + const existingProducts = await this.prisma.product.findMany({ + where: { + businessId, + slug: { contains: '-old-' }, + }, + select: { id: true, slug: true }, + }); + const existingByOldId = new Map(); + for (const p of existingProducts) { + const match = p.slug.match(/-old-(\d+)$/); + if (match) { + existingByOldId.set(Number(match[1]), p.id); + } + } + + const probeMedia = mediaRows + .filter((m) => Number(m.size) > 0) + .slice(0, 12) + .map((m) => ({ id: m.id, fileName: m.file_name })); + const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( + oldBizId, + oldSlug, + probeMedia, + ); + + let created = 0; + let skipped = 0; + let imagesCopied = 0; + let imagesResized = 0; + let imagesFailed = 0; + + for (const [index, row] of products.entries()) { + if (existingByOldId.has(row.id)) { + skipped += 1; + continue; + } + + const titleEn = + (row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product'; + const nameFa = (row.name ?? '').trim() || null; + const baseSlug = slugify(titleEn); + const slug = await this.ensureUniqueProductSlug( + businessId, + baseSlug, + row.id, + ); + + // Re-check in case slug already exists from a prior partial run. + const existingBySlug = await this.prisma.product.findFirst({ + where: { businessId, slug }, + select: { id: true }, + }); + if (existingBySlug) { + existingByOldId.set(row.id, existingBySlug.id); + skipped += 1; + continue; + } + + const productMedia = mediaByProduct.get(row.id) ?? []; + let featuredMediaId: bigint | null = null; + const galleryMediaIds: bigint[] = []; + + for (const media of productMedia) { + const size = Number(media.size) || 0; + if (size <= 0) { + imagesFailed += 1; + continue; + } + + let newMediaId = mediaOldToNew.get(media.id) ?? null; + if (!newMediaId) { + try { + const copied = await this.copyLegacyMedia({ + businessId, + oldMedia: media, + storagePrefix, + }); + newMediaId = copied.id; + mediaOldToNew.set(media.id, newMediaId); + imagesCopied += 1; + if (copied.resized) { + imagesResized += 1; + } + } catch { + imagesFailed += 1; + continue; + } + } + + if (media.collection_name === 'main_image' && !featuredMediaId) { + featuredMediaId = newMediaId; + } else if (media.collection_name === 'images') { + galleryMediaIds.push(newMediaId); + } else if ( + media.collection_name === 'main_image' && + featuredMediaId && + featuredMediaId !== newMediaId + ) { + galleryMediaIds.push(newMediaId); + } + } + + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + const updatedAt = row.updated_at ? new Date(row.updated_at) : createdAt; + const sku = + row.product_code != null && String(row.product_code).trim() + ? String(row.product_code).trim() + : null; + + const product = await this.prisma.product.create({ + data: { + businessId, + title: titleEn, + slug, + description: null, + content: { + nameFa, + html: row.description ?? '', + } as Prisma.InputJsonValue, + sku, + price: null, + stockQuantity: null, + status: ContentStatus.published, + featuredMediaId, + sortOrder: index, + publishedAt: createdAt, + metadata: { + migratedFromOldId: row.id, + legacyBrandId: row.brand_id ?? null, + } as Prisma.InputJsonValue, + createdAt, + updatedAt, + }, + }); + + existingByOldId.set(row.id, product.id); + + const oldCategoryIds = new Set( + categoriesByProduct.get(row.id) ?? [], + ); + if (row.product_category_id != null) { + oldCategoryIds.add(row.product_category_id); + } + + for (const oldCategoryId of oldCategoryIds) { + const newCategoryId = categoryMap.get(oldCategoryId); + if (!newCategoryId) continue; + try { + await this.prisma.categoryAssignment.create({ + data: { + businessId, + categoryId: newCategoryId, + entityType: MediaEntityType.product, + entityId: product.id, + }, + }); + } catch { + // ignore duplicate assignment + } + } + + for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) { + await this.prisma.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.product, + entityId: product.id, + sortOrder: galleryIndex, + isFeatured: false, + }, + }); + } + + created += 1; + } + + return { + created, + skipped, + total: products.length, + imagesCopied, + imagesResized, + imagesFailed, + }; + } + + private async legacyTableExists(tableName: string): Promise { + const rows = await this.legacyMysql.query< + Array + >( + `SELECT COUNT(*) as cnt + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ?`, + [tableName], + ); + return Number(rows[0]?.cnt ?? 0) > 0; + } + + private async legacyColumnExists( + tableName: string, + columnName: string, + ): Promise { + const rows = await this.legacyMysql.query< + Array + >( + `SELECT COUNT(*) as cnt + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = ?`, + [tableName, columnName], + ); + return Number(rows[0]?.cnt ?? 0) > 0; + } + + private async migrateProductCategories( + businessId: bigint, + oldBusinessId: bigint, + ): Promise { + // Legacy WillaEngine schemas vary; detect best-fit table/columns. + const tableCandidates = [ + 'product_categories', + 'products_categories', + 'category_products', + 'categories', + 'product_category', + ]; + + let table: string | null = null; + for (const candidate of tableCandidates) { + if (await this.legacyTableExists(candidate)) { + table = candidate; + break; + } + } + + if (!table) { + throw new Error('Legacy product categories table not found'); + } + + const nameCol = (await this.legacyColumnExists(table, 'name')) + ? 'name' + : 'title'; + const nameEnCol = (await this.legacyColumnExists(table, 'name_en')) + ? 'name_en' + : 'NULL'; + + const parentCol = (await this.legacyColumnExists(table, 'parent_id')) + ? 'parent_id' + : (await this.legacyColumnExists(table, 'parent')) + ? 'parent' + : 'NULL'; + + const slugCol = (await this.legacyColumnExists(table, 'slug')) + ? 'slug' + : (await this.legacyColumnExists(table, 'url_title')) + ? 'url_title' + : 'NULL'; + + const orderCol = (await this.legacyColumnExists(table, '_lft')) + ? '`_lft`' + : 'id'; + + const rows = await this.legacyMysql.query( + `SELECT + id, + ${nameCol} AS name, + ${nameEnCol} AS name_en, + ${parentCol} AS parent_id, + ${slugCol} AS slug, + ${orderCol} AS _lft + FROM ${table} + WHERE business_id = ? + ORDER BY \`_lft\` ASC`, + [Number(oldBusinessId)], + ); + + const sortOrderByOldId = this.siblingSortOrders(rows); + + const existing = await this.prisma.category.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + oldId: { not: null }, + }, + select: { id: true, oldId: true }, + }); + + const oldToNew = new Map(); + for (const row of existing) { + if (row.oldId != null) { + oldToNew.set(Number(row.oldId), row.id); + } + } + + let created = 0; + let skipped = 0; + + for (const row of rows) { + if (oldToNew.has(row.id)) { + skipped += 1; + continue; + } + + const nameEn = + (row.name_en ?? '').trim() || row.name.trim() || 'Category'; + const nameFa = row.name.trim() || null; + const rawSlug = (row.slug ?? '').trim(); + const baseSlug = this.normalizeSlug(rawSlug) || slugify(nameEn); + + const slug = await this.ensureUniqueCategorySlug( + businessId, + MediaEntityType.product, + baseSlug, + row.id, + ); + + const createdRow = await this.prisma.category.create({ + data: { + businessId, + entityType: MediaEntityType.product, + parentId: null, + name: nameEn, + nameFa, + slug, + sortOrder: sortOrderByOldId.get(row.id) ?? 0, + oldId: BigInt(row.id), + isActive: true, + }, + select: { id: true }, + }); + + oldToNew.set(row.id, createdRow.id); + + created += 1; + } + + for (const row of rows) { + if (row.parent_id == null) continue; + const newId = oldToNew.get(row.id); + const newParentId = oldToNew.get(row.parent_id); + if (!newId || !newParentId) continue; + + await this.prisma.category.update({ + where: { id: newId }, + data: { parentId: newParentId }, + }); + } + + // Migrate category variations from legacy `attributes` table. + await this.migrateProductCategoryVariations( + businessId, + oldBusinessId, + oldToNew, + ); + + return { + created, + skipped, + total: rows.length, + }; + } + + private async migrateProductCategoryVariations( + businessId: bigint, + oldBusinessId: bigint, + oldToNew: Map, + ): Promise { + const oldBizId = Number(oldBusinessId); + + const attributes = await this.legacyMysql.query( + `SELECT id, name, attribute_type, option_type, attributable_id + FROM attributes + WHERE business_id = ? + AND attributable_type = 'product_category' + AND is_variation = 1 + ORDER BY id ASC`, + [oldBizId], + ); + + if (!attributes.length) return; + + const attributeIds = attributes.map((a) => a.id); + const attrValues = await this.legacyMysql.query( + `SELECT id, attribute_id, name, value + FROM attribute_values + WHERE attribute_id IN (${attributeIds.map(() => '?').join(',')}) + ORDER BY attribute_id ASC, id ASC`, + attributeIds, + ); + + const valuesByAttr = new Map(); + for (const v of attrValues) { + const list = valuesByAttr.get(v.attribute_id) ?? []; + list.push(v); + valuesByAttr.set(v.attribute_id, list); + } + + // Load already-migrated variations to stay idempotent. + const existingVariations = await this.prisma.categoryVariation.findMany({ + where: { businessId }, + select: { id: true, categoryId: true, name: true }, + }); + const existingVarSet = new Set( + existingVariations.map((v) => `${v.categoryId}:${v.name}`), + ); + + for (const attr of attributes) { + const newCategoryId = oldToNew.get(attr.attributable_id); + if (!newCategoryId) continue; + + const variationName = (attr.name ?? '').trim() || 'Variation'; + const varKey = `${newCategoryId}:${variationName}`; + if (existingVarSet.has(varKey)) continue; + + const variationType = this.legacyAttributeTypeToVariationType( + attr.attribute_type, + ); + + const variation = await this.prisma.categoryVariation.create({ + data: { + businessId, + categoryId: newCategoryId, + name: variationName, + variationType, + sortOrder: 0, + }, + select: { id: true }, + }); + existingVarSet.add(varKey); + + const options = valuesByAttr.get(attr.id) ?? []; + for (const [idx, opt] of options.entries()) { + const label = (opt.name ?? '').trim() || 'Option'; + const value = + (opt.value ?? '').trim() || + slugify(label).slice(0, 100) || + `opt-${opt.id}`; + + try { + await this.prisma.categoryVariationOption.create({ + data: { + variationId: variation.id, + label, + value, + colorHex: + variationType === 'color' + ? this.guessColorHex(label) + : null, + sortOrder: idx, + }, + }); + } catch { + // ignore duplicate value within same variation + } + } + } + } + + private legacyAttributeTypeToVariationType( + attrType: string, + ): 'color' | 'size' | 'custom' { + switch ((attrType ?? '').toLowerCase()) { + case 'color': + return 'color'; + case 'size': + return 'size'; + default: + return 'custom'; + } + } + + /** Returns a hex color string if found in the label, otherwise null. */ + private guessColorHex(label: string): string | null { + const hex = label.match(/#([0-9a-fA-F]{6})\b/)?.[1]; + return hex ? `#${hex}` : null; + } + + private async ensureUniqueProductSlug( + businessId: bigint, + baseSlug: string, + oldId: number, + ): Promise { + // Stable slug for reruns: always include the legacy product id. + // This avoids "Migrate again" creating duplicates due to clash-based slug changes. + void businessId; // slug stability doesn't depend on current DB state + + const base = (baseSlug || 'product').trim().replace(/-+/g, '-'); + const stable = `${base}-old-${oldId}`.replace(/-+/g, '-'); + return stable; + } +} diff --git a/src/business-admin/legacy-purge.service.ts b/src/business-admin/legacy-purge.service.ts new file mode 100644 index 0000000..d5a50d7 --- /dev/null +++ b/src/business-admin/legacy-purge.service.ts @@ -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>> { + const selected = new Set(entities); + const results: Partial< + Record + > = {}; + + // 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 { + 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(); + 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 { + return this.purgeCategories(businessId, MediaEntityType.product); + } + + private async purgePortfolios(businessId: bigint): Promise { + 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(); + 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 { + return this.purgeCategories(businessId, MediaEntityType.portfolio); + } + + private async purgeBlogs(businessId: bigint): Promise { + 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(); + 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 { + return this.purgeCategories(businessId, MediaEntityType.blog); + } + + private async purgeCustomers(businessId: bigint): Promise { + 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 { + return this.purgeCategories(businessId, MediaEntityType.customer); + } + + private async purgeCategories( + businessId: bigint, + entityType: ContentCategoryEntity, + ): Promise { + 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 { + if (!mediaIds.length) return 0; + + const uniqueIds = [...new Set(mediaIds.map((id) => id.toString()))].map( + (id) => BigInt(id), + ); + + const stillLinked = new Set(); + + 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; + } +} diff --git a/src/business-profile/business-profile.service.ts b/src/business-profile/business-profile.service.ts index 5441191..11a92fa 100644 --- a/src/business-profile/business-profile.service.ts +++ b/src/business-profile/business-profile.service.ts @@ -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, diff --git a/src/legacy-mysql/legacy-mysql.module.ts b/src/legacy-mysql/legacy-mysql.module.ts new file mode 100644 index 0000000..5e35b4b --- /dev/null +++ b/src/legacy-mysql/legacy-mysql.module.ts @@ -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 {} diff --git a/src/legacy-mysql/legacy-mysql.service.ts b/src/legacy-mysql/legacy-mysql.service.ts new file mode 100644 index 0000000..c1e726a --- /dev/null +++ b/src/legacy-mysql/legacy-mysql.service.ts @@ -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('OLD_MYSQL_HOST') && + this.config.get('OLD_MYSQL_USER') && + this.config.get('OLD_MYSQL_DATABASE'), + ); + } + + async query( + sql: string, + params: unknown[] = [], + ): Promise { + const pool = this.getPool(); + try { + const [rows] = await pool.query(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('OLD_MYSQL_HOST'), + port: Number(this.config.get('OLD_MYSQL_PORT', '3307')), + user: this.config.getOrThrow('OLD_MYSQL_USER'), + password: this.config.get('OLD_MYSQL_PASSWORD', ''), + database: this.config.getOrThrow('OLD_MYSQL_DATABASE'), + waitForConnections: true, + connectionLimit: 4, + namedPlaceholders: false, + }; + + this.pool = mysql.createPool(options); + return this.pool; + } +} diff --git a/src/legacy-mysql/legacy-source-s3.service.ts b/src/legacy-mysql/legacy-source-s3.service.ts new file mode 100644 index 0000000..7addb0e --- /dev/null +++ b/src/legacy-mysql/legacy-source-s3.service.ts @@ -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(); + + constructor(private readonly config: ConfigService) {} + + async onModuleDestroy() { + this.client?.destroy(); + this.client = null; + } + + isConfigured(): boolean { + return Boolean( + this.config.get('OLD_S3_ENDPOINT') && + this.config.get('OLD_S3_BUCKET') && + this.config.get('OLD_S3_ACCESS_KEY_ID') && + this.config.get('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 { + const cached = this.prefixCache.get(oldBusinessId); + if (cached) return cached; + + const candidates: string[] = []; + const seen = new Set(); + 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 { + 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 { + 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 { + 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('OLD_S3_BUCKET'); + this.publicUrlBase = ( + this.config.get('OLD_S3_PUBLIC_URL') ?? '' + ).replace(/\/$/, ''); + + this.client = new S3Client({ + endpoint: this.config.getOrThrow('OLD_S3_ENDPOINT'), + region: this.config.get('OLD_S3_REGION', 'us-east-1'), + forcePathStyle: + this.config.get('OLD_S3_FORCE_PATH_STYLE', 'true') === 'true', + credentials: { + accessKeyId: this.config.getOrThrow('OLD_S3_ACCESS_KEY_ID'), + secretAccessKey: this.config.getOrThrow( + 'OLD_S3_SECRET_ACCESS_KEY', + ), + }, + }); + + return { client: this.client, bucket: this.bucket }; + } +} diff --git a/src/media/media.service.ts b/src/media/media.service.ts index f646e1c..feed556 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -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, diff --git a/src/portfolios/dto/portfolio.dto.ts b/src/portfolios/dto/portfolio.dto.ts index 1dde54e..af523db 100644 --- a/src/portfolios/dto/portfolio.dto.ts +++ b/src/portfolios/dto/portfolio.dto.ts @@ -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; diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts index 9af13f0..8b6f53b 100644 --- a/src/portfolios/portfolios.service.ts +++ b/src/portfolios/portfolios.service.ts @@ -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) ?? '', diff --git a/src/storage/storage-keys.ts b/src/storage/storage-keys.ts new file mode 100644 index 0000000..36d7f75 --- /dev/null +++ b/src/storage/storage-keys.ts @@ -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}`; +}