From 1c7678ce679500ae75cf0f3f86541927b2dce1cc Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 16 Aug 2026 23:56:16 +0330 Subject: [PATCH] Add website static image slots with page grouping and bilingual captions. Co-authored-by: Cursor --- .../migrations/063_website_static_images.sql | 83 ++++ .../064_website_static_image_item_count.sql | 19 + .../065_website_static_image_page_key.sql | 7 + .../066_website_static_image_captions.sql | 14 + docs/PROJECT_CONTEXT.md | 4 +- docs/website-api/AI_PROMPT.md | 10 +- ...eshkee-Website-API.postman_collection.json | 14 + docs/website-api/openapi.json | 59 +++ prisma/schema.prisma | 48 +- src/business-admin/legacy-purge.service.ts | 11 + .../business-primary-colors.ts | 9 + src/website-docs/static/AI_PROMPT.md | 10 +- ...eshkee-Website-API.postman_collection.json | 14 + src/website-docs/static/openapi.json | 59 +++ src/website/dto/website-static-images.dto.ts | 222 ++++++++++ .../website-static-images.controller.ts | 98 +++++ src/website/website-static-images.service.ts | 415 ++++++++++++++++++ src/website/website.module.ts | 8 + 18 files changed, 1098 insertions(+), 6 deletions(-) create mode 100644 database/migrations/063_website_static_images.sql create mode 100644 database/migrations/064_website_static_image_item_count.sql create mode 100644 database/migrations/065_website_static_image_page_key.sql create mode 100644 database/migrations/066_website_static_image_captions.sql create mode 100644 src/website/dto/website-static-images.dto.ts create mode 100644 src/website/website-static-images.controller.ts create mode 100644 src/website/website-static-images.service.ts diff --git a/database/migrations/063_website_static_images.sql b/database/migrations/063_website_static_images.sql new file mode 100644 index 0000000..e093e83 --- /dev/null +++ b/database/migrations/063_website_static_images.sql @@ -0,0 +1,83 @@ +-- Meshkee CMS — website static image slots (single or list, optional link per image) + +CREATE TABLE website_image_slots ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + slot_key VARCHAR(100) NOT NULL, + kind VARCHAR(20) NOT NULL, + label VARCHAR(255) NOT NULL, + aspect_ratio VARCHAR(20), + recommended_width INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT website_image_slots_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT website_image_slots_kind_check + CHECK (kind IN ('single', 'list')), + CONSTRAINT website_image_slots_aspect_ratio_check + CHECK (aspect_ratio IS NULL OR aspect_ratio ~ '^[0-9]{1,3}:[0-9]{1,3}$'), + CONSTRAINT website_image_slots_recommended_width_check + CHECK (recommended_width IS NULL OR recommended_width > 0) +); + +CREATE UNIQUE INDEX website_image_slots_business_id_slot_key_unique + ON website_image_slots (business_id, slot_key); + +CREATE INDEX idx_website_image_slots_business_id + ON website_image_slots (business_id); + +CREATE TRIGGER website_image_slots_set_updated_at + BEFORE UPDATE ON website_image_slots + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE website_image_slot_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slot_id BIGINT NOT NULL, + image_media_id BIGINT NOT NULL, + title VARCHAR(255), + link_url VARCHAR(2048), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT website_image_slot_items_slot_id_fkey + FOREIGN KEY (slot_id) REFERENCES website_image_slots (id) ON DELETE CASCADE, + CONSTRAINT website_image_slot_items_image_media_id_fkey + FOREIGN KEY (image_media_id) REFERENCES media (id) ON DELETE RESTRICT +); + +CREATE INDEX idx_website_image_slot_items_slot_id + ON website_image_slot_items (slot_id); + +CREATE INDEX idx_website_image_slot_items_image_media_id + ON website_image_slot_items (image_media_id); + +CREATE TRIGGER website_image_slot_items_set_updated_at + BEFORE UPDATE ON website_image_slot_items + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- First test slot for live sanihome.ir (no-op if that domain is not in this database) +INSERT INTO website_image_slots ( + business_id, + slot_key, + kind, + label, + aspect_ratio, + recommended_width, + sort_order +) +SELECT DISTINCT ON (d.business_id) + d.business_id, + 'home-hero', + 'list', + 'Home hero', + '16:9', + 1440, + 0 +FROM domains d +WHERE lower(regexp_replace(d.host, '^www\.', '')) = 'sanihome.ir' +ON CONFLICT (business_id, slot_key) DO NOTHING; diff --git a/database/migrations/064_website_static_image_item_count.sql b/database/migrations/064_website_static_image_item_count.sql new file mode 100644 index 0000000..edd703b --- /dev/null +++ b/database/migrations/064_website_static_image_item_count.sql @@ -0,0 +1,19 @@ +-- Fixed slot count: NULL = unlimited list; 1 for single; N for a row of N uploaders + +ALTER TABLE website_image_slots + ADD COLUMN IF NOT EXISTS item_count INTEGER; + +ALTER TABLE website_image_slots + DROP CONSTRAINT IF EXISTS website_image_slots_item_count_check; + +ALTER TABLE website_image_slots + ADD CONSTRAINT website_image_slots_item_count_check + CHECK (item_count IS NULL OR item_count >= 1); + +UPDATE website_image_slots +SET item_count = 1 +WHERE kind = 'single' AND item_count IS NULL; + +UPDATE website_image_slots +SET item_count = 2 +WHERE slot_key = 'two-banner-row' AND kind = 'list'; diff --git a/database/migrations/065_website_static_image_page_key.sql b/database/migrations/065_website_static_image_page_key.sql new file mode 100644 index 0000000..8d5b535 --- /dev/null +++ b/database/migrations/065_website_static_image_page_key.sql @@ -0,0 +1,7 @@ +-- Group static image slots by website page (home, products, about, …) + +ALTER TABLE website_image_slots + ADD COLUMN IF NOT EXISTS page_key VARCHAR(100) NOT NULL DEFAULT 'home'; + +CREATE INDEX IF NOT EXISTS idx_website_image_slots_business_id_page_key + ON website_image_slots (business_id, page_key); diff --git a/database/migrations/066_website_static_image_captions.sql b/database/migrations/066_website_static_image_captions.sql new file mode 100644 index 0000000..a9866ee --- /dev/null +++ b/database/migrations/066_website_static_image_captions.sql @@ -0,0 +1,14 @@ +-- Bilingual titles + subtext on each static image item + +ALTER TABLE website_image_slot_items + ADD COLUMN IF NOT EXISTS title_fa VARCHAR(255); + +ALTER TABLE website_image_slot_items + ADD COLUMN IF NOT EXISTS title_en VARCHAR(255); + +ALTER TABLE website_image_slot_items + ADD COLUMN IF NOT EXISTS subtext VARCHAR(500); + +UPDATE website_image_slot_items +SET title_fa = title +WHERE title_fa IS NULL AND title IS NOT NULL; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 988c63d..4c881c6 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -265,6 +265,8 @@ All routes are prefixed with `/api/v1`. | GET | `/tenants/:host/website/category-groups` | Homepage category rows | | GET | `/tenants/:host/website/brand-groups` | Homepage brand rows | | GET | `/tenants/:host/website/sliders` | Homepage sliders with slides | +| GET | `/tenants/:host/website/static-images` | Named static image slots (`?pageKey=` optional) | +| GET | `/tenants/:host/website/static-images/:key` | One static image slot by key | ### Authenticated (JWT) @@ -294,7 +296,7 @@ Pattern: `/businesses/:businessId/` | Media | `/media` | `media.*` | | Categories | `/categories` | `categories.*` | | Products | `/products` | `products.*` | -| Website | `/website/category-groups`, `/website/brand-groups`, `/website/sliders` | `website.*` | +| Website | `/website/category-groups`, `/website/brand-groups`, `/website/sliders`, `/website/static-images` | `website.*` | #### Categories — notable sub-routes diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 09d791a..83fe8aa 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -30,7 +30,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` -2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials +2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials 3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` @@ -46,6 +46,14 @@ Public marketplace listings owned by customers — not catalog `products`. - `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing. +### Static images +Named slots the business dashboard can replace. Fetch once per page: +- `GET /tenants/{domain}/website/static-images` — all slots +- `GET /tenants/{domain}/website/static-images?pageKey=home` — slots for one page (`home`, `products`, `about`, …) +- `GET /tenants/{domain}/website/static-images/{key}` — one slot (e.g. `home-hero`) + +Use `slot.key` in the placeholder. Match `pageKey` to the website page. For a single image: `images[0]?.url`. For a list: map `images` (if `itemCount` is set, that many images are expected). Each image may include `titleFa`, `titleEn`, `subtext`, and `linkUrl`. If `linkUrl` is set, wrap in ``. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback. + If OpenAPI and this brief conflict, **OpenAPI wins**. --- diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 0e449a6..81e8026 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -1489,6 +1489,20 @@ "url": "{{baseUrl}}/tenants/{{domain}}/website/sliders" } }, + { + "name": "List static images (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/static-images?pageKey=home" + } + }, + { + "name": "Get static image slot by key (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/static-images/home-hero" + } + }, { "name": "List store specials (website)", "request": { diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index cdbe4bf..c0e3a88 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -214,6 +214,65 @@ } } }, + "/tenants/{domain}/website/static-images": { + "get": { + "tags": [ + "Homepage" + ], + "summary": "Named static image slots (single or list)", + "description": "Each slot has a stable `key` used in website placeholders and a `pageKey` (`home`, `products`, `about`, …). Filter with `?pageKey=home`. `kind` is `single` (one image) or `list` (ordered images). `itemCount` is the expected number of images when fixed (e.g. 2 for a two-banner row); omit/null means an unbounded list. Every image may include optional `titleFa`, `titleEn`, `subtext`, and `linkUrl`. Empty `images` means show the site fallback.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "pageKey", + "in": "query", + "required": false, + "schema": { + "type": "string", + "example": "home" + }, + "description": "If set, only slots for that website page are returned." + } + ], + "responses": { + "200": { + "description": "{ items: StaticImageSlot[] } — key, pageKey, kind, label, aspectRatio, recommendedWidth, itemCount, images[{ id, url, titleFa, titleEn, subtext, linkUrl, width, height, sortOrder }]" + } + } + } + }, + "/tenants/{domain}/website/static-images/{key}": { + "get": { + "tags": [ + "Homepage" + ], + "summary": "One static image slot by key", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "home-hero" + } + } + ], + "responses": { + "200": { + "description": "StaticImageSlot" + }, + "404": { + "description": "Unknown slot key for this domain" + } + } + } + }, "/tenants/{domain}/website/category-groups": { "get": { "tags": [ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b6a4f5..5957a3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,6 +108,7 @@ model Business { userProducts UserProduct[] website_brand_groups website_brand_groups[] website_category_groups website_category_groups[] + website_image_slots website_image_slots[] website_sliders website_sliders[] @@map("businesses") @@ -289,9 +290,10 @@ model Media { portfolios portfolios[] videos videos[] instructions instructions[] - featuredProducts Product[] @relation("ProductFeaturedMedia") - featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia") - website_slider_slides website_slider_slides[] + featuredProducts Product[] @relation("ProductFeaturedMedia") + featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia") + website_image_slot_items website_image_slot_items[] + website_slider_slides website_slider_slides[] @@index([businessId], map: "idx_media_business_id") @@index([businessId, mediaType], map: "idx_media_business_type") @@ -1113,6 +1115,46 @@ model website_category_groups { @@index([business_id], map: "idx_website_category_groups_business_id") } +model website_image_slot_items { + id BigInt @id @default(autoincrement()) + slot_id BigInt + image_media_id BigInt + title String? @db.VarChar(255) + title_fa String? @db.VarChar(255) + title_en String? @db.VarChar(255) + subtext String? @db.VarChar(500) + link_url String? @db.VarChar(2048) + sort_order Int @default(0) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + media Media @relation(fields: [image_media_id], references: [id], onUpdate: NoAction) + website_image_slots website_image_slots @relation(fields: [slot_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([image_media_id], map: "idx_website_image_slot_items_image_media_id") + @@index([slot_id], map: "idx_website_image_slot_items_slot_id") +} + +model website_image_slots { + id BigInt @id @default(autoincrement()) + business_id BigInt + slot_key String @db.VarChar(100) + kind String @db.VarChar(20) + label String @db.VarChar(255) + aspect_ratio String? @db.VarChar(20) + recommended_width Int? + item_count Int? + page_key String @default("home") @db.VarChar(100) + sort_order Int @default(0) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + website_image_slot_items website_image_slot_items[] + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([business_id, slot_key], map: "website_image_slots_business_id_slot_key_unique") + @@index([business_id], map: "idx_website_image_slots_business_id") + @@index([business_id, page_key], map: "idx_website_image_slots_business_id_page_key") +} + model website_slider_slides { id BigInt @id @default(autoincrement()) slider_id BigInt diff --git a/src/business-admin/legacy-purge.service.ts b/src/business-admin/legacy-purge.service.ts index 7c9164d..a642e9f 100644 --- a/src/business-admin/legacy-purge.service.ts +++ b/src/business-admin/legacy-purge.service.ts @@ -512,6 +512,17 @@ export class LegacyPurgeService { stillLinked.add(row.image_media_id.toString()); } + const staticImageItems = await this.prisma.website_image_slot_items.findMany({ + where: { + image_media_id: { in: uniqueIds }, + website_image_slots: { business_id: businessId }, + }, + select: { image_media_id: true }, + }); + for (const row of staticImageItems) { + stillLinked.add(row.image_media_id.toString()); + } + const deletableIds = uniqueIds.filter( (id) => !stillLinked.has(id.toString()), ); diff --git a/src/business-settings/business-primary-colors.ts b/src/business-settings/business-primary-colors.ts index 86b1d4a..bb4c5b0 100644 --- a/src/business-settings/business-primary-colors.ts +++ b/src/business-settings/business-primary-colors.ts @@ -1,5 +1,6 @@ export const BUSINESS_PRIMARY_COLOR_IDS = [ 'red', + 'dark-red', 'yellow', 'black', 'cyan', @@ -38,6 +39,14 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record< primaryGlow: '#ef4444', primaryRgb: '239 68 68', }, + 'dark-red': { + label: 'Dark Red', + primary: '#991b1b', + primaryDark: '#7f1d1d', + primaryLight: '#fee2e2', + primaryGlow: '#b91c1c', + primaryRgb: '153 27 27', + }, yellow: { label: 'Yellow', primary: '#eab308', diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 09d791a..83fe8aa 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -30,7 +30,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` -2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials +2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials 3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` @@ -46,6 +46,14 @@ Public marketplace listings owned by customers — not catalog `products`. - `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing. +### Static images +Named slots the business dashboard can replace. Fetch once per page: +- `GET /tenants/{domain}/website/static-images` — all slots +- `GET /tenants/{domain}/website/static-images?pageKey=home` — slots for one page (`home`, `products`, `about`, …) +- `GET /tenants/{domain}/website/static-images/{key}` — one slot (e.g. `home-hero`) + +Use `slot.key` in the placeholder. Match `pageKey` to the website page. For a single image: `images[0]?.url`. For a list: map `images` (if `itemCount` is set, that many images are expected). Each image may include `titleFa`, `titleEn`, `subtext`, and `linkUrl`. If `linkUrl` is set, wrap in ``. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback. + If OpenAPI and this brief conflict, **OpenAPI wins**. --- diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index 0e449a6..81e8026 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -1489,6 +1489,20 @@ "url": "{{baseUrl}}/tenants/{{domain}}/website/sliders" } }, + { + "name": "List static images (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/static-images?pageKey=home" + } + }, + { + "name": "Get static image slot by key (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/static-images/home-hero" + } + }, { "name": "List store specials (website)", "request": { diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index cdbe4bf..c0e3a88 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -214,6 +214,65 @@ } } }, + "/tenants/{domain}/website/static-images": { + "get": { + "tags": [ + "Homepage" + ], + "summary": "Named static image slots (single or list)", + "description": "Each slot has a stable `key` used in website placeholders and a `pageKey` (`home`, `products`, `about`, …). Filter with `?pageKey=home`. `kind` is `single` (one image) or `list` (ordered images). `itemCount` is the expected number of images when fixed (e.g. 2 for a two-banner row); omit/null means an unbounded list. Every image may include optional `titleFa`, `titleEn`, `subtext`, and `linkUrl`. Empty `images` means show the site fallback.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "pageKey", + "in": "query", + "required": false, + "schema": { + "type": "string", + "example": "home" + }, + "description": "If set, only slots for that website page are returned." + } + ], + "responses": { + "200": { + "description": "{ items: StaticImageSlot[] } — key, pageKey, kind, label, aspectRatio, recommendedWidth, itemCount, images[{ id, url, titleFa, titleEn, subtext, linkUrl, width, height, sortOrder }]" + } + } + } + }, + "/tenants/{domain}/website/static-images/{key}": { + "get": { + "tags": [ + "Homepage" + ], + "summary": "One static image slot by key", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "home-hero" + } + } + ], + "responses": { + "200": { + "description": "StaticImageSlot" + }, + "404": { + "description": "Unknown slot key for this domain" + } + } + } + }, "/tenants/{domain}/website/category-groups": { "get": { "tags": [ diff --git a/src/website/dto/website-static-images.dto.ts b/src/website/dto/website-static-images.dto.ts new file mode 100644 index 0000000..fd1a5dd --- /dev/null +++ b/src/website/dto/website-static-images.dto.ts @@ -0,0 +1,222 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsIn, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; +import { + SPECIAL_GROUP_KEY_MESSAGE, + SPECIAL_GROUP_KEY_PATTERN, +} from '../../common/special-group-key'; + +const emptyToUndefined = ({ value }: { value: unknown }) => { + if (value == null) return undefined; + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +}; + +export class WebsiteStaticImageItemInputDto { + @IsString() + imageMediaId!: string; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MaxLength(255) + title?: string; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MaxLength(255) + titleFa?: string; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MaxLength(255) + titleEn?: string; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MaxLength(500) + subtext?: string; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MaxLength(2048) + @Matches(/^(https?:\/\/[^\s]+|\/[^\s]*)$/i, { + message: 'linkUrl must be an absolute or site-relative URL', + }) + linkUrl?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class CreateWebsiteStaticImageSlotDto { + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key!: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + label!: string; + + @IsIn(['single', 'list']) + kind!: 'single' | 'list'; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @Matches(/^[0-9]{1,3}:[0-9]{1,3}$/, { + message: 'aspectRatio must look like 16:9', + }) + aspectRatio?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(8000) + recommendedWidth?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(24) + itemCount?: number; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + pageKey?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WebsiteStaticImageItemInputDto) + items?: WebsiteStaticImageItemInputDto[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; +} + +export class UpdateWebsiteStaticImageSlotDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + label?: string; + + @IsOptional() + @IsIn(['single', 'list']) + kind?: 'single' | 'list'; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @Matches(/^[0-9]{1,3}:[0-9]{1,3}$/, { + message: 'aspectRatio must look like 16:9', + }) + aspectRatio?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(8000) + recommendedWidth?: number | null; + + @IsOptional() + @Transform(({ value }) => { + if (value === undefined) return undefined + if (value === null || value === '') return null + return Number(value) + }) + @IsInt() + @Min(1) + @Max(24) + itemCount?: number | null; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + pageKey?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WebsiteStaticImageItemInputDto) + items?: WebsiteStaticImageItemInputDto[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; +} + +export class ListWebsiteStaticImageSlotsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + pageKey?: string; +} + +export class PublicWebsiteStaticImagesQueryDto { + @IsOptional() + @Transform(emptyToUndefined) + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + pageKey?: string; +} diff --git a/src/website/website-static-images.controller.ts b/src/website/website-static-images.controller.ts new file mode 100644 index 0000000..09a4a05 --- /dev/null +++ b/src/website/website-static-images.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateWebsiteStaticImageSlotDto, + ListWebsiteStaticImageSlotsDto, + PublicWebsiteStaticImagesQueryDto, + UpdateWebsiteStaticImageSlotDto, +} from './dto/website-static-images.dto'; +import { WebsiteStaticImagesService } from './website-static-images.service'; + +@Controller('tenants/:host/website/static-images') +export class PublicWebsiteStaticImagesController { + constructor(private readonly service: WebsiteStaticImagesService) {} + + @Get() + list( + @Param('host') host: string, + @Query() query: PublicWebsiteStaticImagesQueryDto, + ) { + return this.service.listPublic(host, query.pageKey); + } + + @Get(':key') + getOne(@Param('host') host: string, @Param('key') key: string) { + return this.service.getPublicByKey(host, key); + } +} + +@Controller('businesses/:businessId/website/static-images') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class WebsiteStaticImagesController { + constructor(private readonly service: WebsiteStaticImagesService) {} + + @Get() + @RequireBusinessPermission('website.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListWebsiteStaticImageSlotsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':slotId') + @RequireBusinessPermission('website.read') + getOne( + @Param('businessId') businessId: string, + @Param('slotId') slotId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, slotId, user); + } + + @Post() + @RequireBusinessPermission('website.update') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateWebsiteStaticImageSlotDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':slotId') + @RequireBusinessPermission('website.update') + update( + @Param('businessId') businessId: string, + @Param('slotId') slotId: string, + @Body() dto: UpdateWebsiteStaticImageSlotDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, slotId, dto, user); + } + + @Delete(':slotId') + @RequireBusinessPermission('website.update') + remove( + @Param('businessId') businessId: string, + @Param('slotId') slotId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, slotId, user); + } +} diff --git a/src/website/website-static-images.service.ts b/src/website/website-static-images.service.ts new file mode 100644 index 0000000..c629d1e --- /dev/null +++ b/src/website/website-static-images.service.ts @@ -0,0 +1,415 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { normalizeSpecialGroupKey } from '../common/special-group-key'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateWebsiteStaticImageSlotDto, + ListWebsiteStaticImageSlotsDto, + UpdateWebsiteStaticImageSlotDto, + WebsiteStaticImageItemInputDto, +} from './dto/website-static-images.dto'; + +const slotInclude = { + website_image_slot_items: { + orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }], + include: { + media: true, + }, + }, +} satisfies Prisma.website_image_slotsInclude; + +type SlotWithItems = Prisma.website_image_slotsGetPayload<{ + include: typeof slotInclude; +}>; + +function resolvePageKey(raw?: string | null): string { + const key = raw?.trim() ? normalizeSpecialGroupKey(raw) : 'home'; + return key || 'home'; +} + +function resolveItemCount( + kind: string, + itemCount?: number | null, +): number | null { + if (kind === 'single') return 1; + if (itemCount == null) return null; + return itemCount; +} + +@Injectable() +export class WebsiteStaticImagesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async listPublic(host: string, pageKey?: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const slots = await this.prisma.website_image_slots.findMany({ + where: { + business_id: business.id, + ...(pageKey ? { page_key: resolvePageKey(pageKey) } : {}), + }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + include: slotInclude, + }); + + return { + items: slots.map((slot) => this.serializeSlot(slot, true)), + }; + } + + async getPublicByKey(host: string, keyRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const key = normalizeSpecialGroupKey(keyRaw); + const slot = await this.prisma.website_image_slots.findFirst({ + where: { business_id: business.id, slot_key: key }, + include: slotInclude, + }); + + if (!slot) { + throw new NotFoundException('Static image slot not found'); + } + + return this.serializeSlot(slot, true); + } + + async list( + businessIdRaw: string, + query: ListWebsiteStaticImageSlotsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 50; + const skip = (page - 1) * pageSize; + + const where: Prisma.website_image_slotsWhereInput = { + business_id: businessId, + ...(query.pageKey ? { page_key: resolvePageKey(query.pageKey) } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.website_image_slots.findMany({ + where, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + skip, + take: pageSize, + include: slotInclude, + }), + this.prisma.website_image_slots.count({ where }), + ]); + + return { + items: items.map((slot) => this.serializeSlot(slot, false)), + total, + page, + pageSize, + }; + } + + async getOne(businessIdRaw: string, slotIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const slotId = BigInt(slotIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + const slot = await this.findSlotOrThrow(businessId, slotId); + return { slot: this.serializeSlot(slot, false) }; + } + + async create( + businessIdRaw: string, + dto: CreateWebsiteStaticImageSlotDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + const key = normalizeSpecialGroupKey(dto.key); + const kind = dto.kind; + const itemCount = resolveItemCount(kind, dto.itemCount); + const items = dto.items ?? []; + this.assertItemCount(kind, itemCount, items.length); + await this.assertKeyAvailable(businessId, key); + await this.assertItemMedia(businessId, items); + + const created = await this.prisma.$transaction(async (tx) => { + const slot = await tx.website_image_slots.create({ + data: { + business_id: businessId, + slot_key: key, + kind, + label: dto.label.trim(), + aspect_ratio: dto.aspectRatio?.trim() || null, + recommended_width: dto.recommendedWidth ?? null, + item_count: itemCount, + page_key: resolvePageKey(dto.pageKey), + sort_order: dto.sortOrder ?? 0, + }, + }); + + await this.replaceItems(tx, slot.id, items); + return tx.website_image_slots.findUniqueOrThrow({ + where: { id: slot.id }, + include: slotInclude, + }); + }); + + return { + message: 'Static image slot created successfully', + slot: this.serializeSlot(created, false), + }; + } + + async update( + businessIdRaw: string, + slotIdRaw: string, + dto: UpdateWebsiteStaticImageSlotDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const slotId = BigInt(slotIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + const existing = await this.findSlotOrThrow(businessId, slotId); + const nextKind = dto.kind ?? existing.kind; + if (nextKind !== 'single' && nextKind !== 'list') { + throw new BadRequestException('kind must be single or list'); + } + const nextItemCount = + dto.itemCount !== undefined + ? resolveItemCount(nextKind, dto.itemCount) + : resolveItemCount(nextKind, existing.item_count); + + if (dto.key !== undefined) { + await this.assertKeyAvailable( + businessId, + normalizeSpecialGroupKey(dto.key), + slotId, + ); + } + + if (dto.items !== undefined) { + this.assertItemCount(nextKind, nextItemCount, dto.items.length); + await this.assertItemMedia(businessId, dto.items); + } else if (nextKind === 'single' && existing.website_image_slot_items.length > 1) { + throw new BadRequestException( + 'A single slot can only keep one image. Remove extra images first.', + ); + } else { + this.assertItemCount( + nextKind, + nextItemCount, + existing.website_image_slot_items.length, + ); + } + + const updated = await this.prisma.$transaction(async (tx) => { + await tx.website_image_slots.update({ + where: { id: slotId }, + data: { + ...(dto.key !== undefined + ? { slot_key: normalizeSpecialGroupKey(dto.key) } + : {}), + ...(dto.label !== undefined ? { label: dto.label.trim() } : {}), + ...(dto.kind !== undefined ? { kind: dto.kind } : {}), + ...(dto.aspectRatio !== undefined + ? { aspect_ratio: dto.aspectRatio?.trim() || null } + : {}), + ...(dto.recommendedWidth !== undefined + ? { recommended_width: dto.recommendedWidth } + : {}), + ...(dto.itemCount !== undefined ? { item_count: nextItemCount } : {}), + ...(dto.pageKey !== undefined + ? { page_key: resolvePageKey(dto.pageKey) } + : {}), + ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), + }, + }); + + if (dto.items !== undefined) { + await this.replaceItems(tx, slotId, dto.items); + } + + return tx.website_image_slots.findUniqueOrThrow({ + where: { id: slotId }, + include: slotInclude, + }); + }); + + return { + message: 'Static image slot updated successfully', + slot: this.serializeSlot(updated, false), + }; + } + + async remove(businessIdRaw: string, slotIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const slotId = BigInt(slotIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findSlotOrThrow(businessId, slotId); + await this.prisma.website_image_slots.delete({ where: { id: slotId } }); + + return { message: 'Static image slot deleted successfully' }; + } + + private assertItemCount( + kind: string, + itemCount: number | null, + count: number, + ) { + const max = kind === 'single' ? 1 : itemCount; + if (max != null && count > max) { + throw new BadRequestException( + `This slot can only have ${max} image${max === 1 ? '' : 's'}`, + ); + } + } + + private async findSlotOrThrow(businessId: bigint, slotId: bigint) { + const slot = await this.prisma.website_image_slots.findFirst({ + where: { id: slotId, business_id: businessId }, + include: slotInclude, + }); + + if (!slot) { + throw new NotFoundException('Static image slot not found'); + } + + return slot; + } + + private async assertKeyAvailable( + businessId: bigint, + key: string, + excludeId?: bigint, + ) { + const existing = await this.prisma.website_image_slots.findFirst({ + where: { + business_id: businessId, + slot_key: key, + ...(excludeId !== undefined ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (existing) { + throw new ConflictException('A static image slot with this key already exists'); + } + } + + private async assertItemMedia( + businessId: bigint, + items: WebsiteStaticImageItemInputDto[], + ) { + if (items.length === 0) return; + + const uniqueMediaIds = [ + ...new Set(items.map((item) => item.imageMediaId)), + ].map((id) => BigInt(id)); + + const found = await this.prisma.media.findMany({ + where: { + businessId, + id: { in: uniqueMediaIds }, + mimeType: { startsWith: 'image/' }, + }, + select: { id: true }, + }); + + if (found.length !== uniqueMediaIds.length) { + throw new BadRequestException( + 'One or more images were not found for this business', + ); + } + } + + private async replaceItems( + tx: Prisma.TransactionClient, + slotId: bigint, + items: WebsiteStaticImageItemInputDto[], + ) { + await tx.website_image_slot_items.deleteMany({ where: { slot_id: slotId } }); + + if (items.length === 0) return; + + await tx.website_image_slot_items.createMany({ + data: items.map((item, index) => { + const titleFa = item.titleFa?.trim() || item.title?.trim() || null; + const titleEn = item.titleEn?.trim() || null; + return { + slot_id: slotId, + image_media_id: BigInt(item.imageMediaId), + title: titleFa || titleEn, + title_fa: titleFa, + title_en: titleEn, + subtext: item.subtext?.trim() || null, + link_url: item.linkUrl?.trim() || null, + sort_order: item.sortOrder ?? index, + }; + }), + }); + } + + private serializeSlot(slot: SlotWithItems, publicView: boolean) { + const images = slot.website_image_slot_items.map((item) => ({ + id: item.id.toString(), + ...(publicView + ? {} + : { imageMediaId: item.image_media_id.toString() }), + url: item.media.publicUrl, + title: item.title_fa || item.title_en || item.title, + titleFa: item.title_fa, + titleEn: item.title_en, + subtext: item.subtext, + linkUrl: item.link_url, + width: item.media.width, + height: item.media.height, + sortOrder: item.sort_order, + })); + + return { + id: slot.id.toString(), + key: slot.slot_key, + kind: slot.kind, + label: slot.label, + aspectRatio: slot.aspect_ratio, + recommendedWidth: slot.recommended_width, + itemCount: resolveItemCount(slot.kind, slot.item_count), + pageKey: slot.page_key, + sortOrder: slot.sort_order, + createdAt: slot.created_at, + updatedAt: slot.updated_at, + images, + }; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/website/website.module.ts b/src/website/website.module.ts index 4459fcc..be97db1 100644 --- a/src/website/website.module.ts +++ b/src/website/website.module.ts @@ -20,6 +20,11 @@ import { WebsiteSlidersController, } from './website-sliders.controller'; import { WebsiteSlidersService } from './website-sliders.service'; +import { + PublicWebsiteStaticImagesController, + WebsiteStaticImagesController, +} from './website-static-images.controller'; +import { WebsiteStaticImagesService } from './website-static-images.service'; @Module({ imports: [AuthModule, TenantModule], @@ -30,12 +35,15 @@ import { WebsiteSlidersService } from './website-sliders.service'; WebsiteBrandGroupsController, PublicWebsiteSlidersController, WebsiteSlidersController, + PublicWebsiteStaticImagesController, + WebsiteStaticImagesController, PublicWebsiteBusinessInfoController, ], providers: [ WebsiteCategoryGroupsService, WebsiteBrandGroupsService, WebsiteSlidersService, + WebsiteStaticImagesService, WebsiteBusinessInfoService, ], })