Add website static image slots with page grouping and bilingual captions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-16 23:56:16 +03:30
co-authored by Cursor
parent b991a60cbc
commit 1c7678ce67
18 changed files with 1098 additions and 6 deletions
@@ -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;
@@ -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';
@@ -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);
@@ -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;
+3 -1
View File
@@ -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/<resource>`
| 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
+9 -1
View File
@@ -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 `<a href={linkUrl}>`. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback.
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
@@ -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": {
+59
View File
@@ -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": [
+45 -3
View File
@@ -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
@@ -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()),
);
@@ -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',
+9 -1
View File
@@ -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 `<a href={linkUrl}>`. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback.
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
@@ -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": {
+59
View File
@@ -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": [
@@ -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;
}
@@ -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);
}
}
@@ -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`,
);
}
}
}
+8
View File
@@ -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,
],
})