Add website static image slots with page grouping and bilingual captions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b991a60cbc
commit
1c7678ce67
@@ -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;
|
||||||
@@ -265,6 +265,8 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
||||||
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
||||||
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
| 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)
|
### Authenticated (JWT)
|
||||||
|
|
||||||
@@ -294,7 +296,7 @@ Pattern: `/businesses/:businessId/<resource>`
|
|||||||
| Media | `/media` | `media.*` |
|
| Media | `/media` | `media.*` |
|
||||||
| Categories | `/categories` | `categories.*` |
|
| Categories | `/categories` | `categories.*` |
|
||||||
| Products | `/products` | `products.*` |
|
| 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
|
#### Categories — notable sub-routes
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
|
|
||||||
### Typical bootstrap sequence
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
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)
|
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).
|
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`
|
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
|
- `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.
|
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**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1489,6 +1489,20 @@
|
|||||||
"url": "{{baseUrl}}/tenants/{{domain}}/website/sliders"
|
"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)",
|
"name": "List store specials (website)",
|
||||||
"request": {
|
"request": {
|
||||||
|
|||||||
@@ -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": {
|
"/tenants/{domain}/website/category-groups": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
+45
-3
@@ -108,6 +108,7 @@ model Business {
|
|||||||
userProducts UserProduct[]
|
userProducts UserProduct[]
|
||||||
website_brand_groups website_brand_groups[]
|
website_brand_groups website_brand_groups[]
|
||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
|
website_image_slots website_image_slots[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
|
|
||||||
@@map("businesses")
|
@@map("businesses")
|
||||||
@@ -289,9 +290,10 @@ model Media {
|
|||||||
portfolios portfolios[]
|
portfolios portfolios[]
|
||||||
videos videos[]
|
videos videos[]
|
||||||
instructions instructions[]
|
instructions instructions[]
|
||||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||||
website_slider_slides website_slider_slides[]
|
website_image_slot_items website_image_slot_items[]
|
||||||
|
website_slider_slides website_slider_slides[]
|
||||||
|
|
||||||
@@index([businessId], map: "idx_media_business_id")
|
@@index([businessId], map: "idx_media_business_id")
|
||||||
@@index([businessId, mediaType], map: "idx_media_business_type")
|
@@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")
|
@@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 {
|
model website_slider_slides {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
slider_id BigInt
|
slider_id BigInt
|
||||||
|
|||||||
@@ -512,6 +512,17 @@ export class LegacyPurgeService {
|
|||||||
stillLinked.add(row.image_media_id.toString());
|
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(
|
const deletableIds = uniqueIds.filter(
|
||||||
(id) => !stillLinked.has(id.toString()),
|
(id) => !stillLinked.has(id.toString()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const BUSINESS_PRIMARY_COLOR_IDS = [
|
export const BUSINESS_PRIMARY_COLOR_IDS = [
|
||||||
'red',
|
'red',
|
||||||
|
'dark-red',
|
||||||
'yellow',
|
'yellow',
|
||||||
'black',
|
'black',
|
||||||
'cyan',
|
'cyan',
|
||||||
@@ -38,6 +39,14 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
|
|||||||
primaryGlow: '#ef4444',
|
primaryGlow: '#ef4444',
|
||||||
primaryRgb: '239 68 68',
|
primaryRgb: '239 68 68',
|
||||||
},
|
},
|
||||||
|
'dark-red': {
|
||||||
|
label: 'Dark Red',
|
||||||
|
primary: '#991b1b',
|
||||||
|
primaryDark: '#7f1d1d',
|
||||||
|
primaryLight: '#fee2e2',
|
||||||
|
primaryGlow: '#b91c1c',
|
||||||
|
primaryRgb: '153 27 27',
|
||||||
|
},
|
||||||
yellow: {
|
yellow: {
|
||||||
label: 'Yellow',
|
label: 'Yellow',
|
||||||
primary: '#eab308',
|
primary: '#eab308',
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
|
|
||||||
### Typical bootstrap sequence
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
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)
|
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).
|
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`
|
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
|
- `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.
|
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**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1489,6 +1489,20 @@
|
|||||||
"url": "{{baseUrl}}/tenants/{{domain}}/website/sliders"
|
"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)",
|
"name": "List store specials (website)",
|
||||||
"request": {
|
"request": {
|
||||||
|
|||||||
@@ -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": {
|
"/tenants/{domain}/website/category-groups": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
WebsiteSlidersController,
|
WebsiteSlidersController,
|
||||||
} from './website-sliders.controller';
|
} from './website-sliders.controller';
|
||||||
import { WebsiteSlidersService } from './website-sliders.service';
|
import { WebsiteSlidersService } from './website-sliders.service';
|
||||||
|
import {
|
||||||
|
PublicWebsiteStaticImagesController,
|
||||||
|
WebsiteStaticImagesController,
|
||||||
|
} from './website-static-images.controller';
|
||||||
|
import { WebsiteStaticImagesService } from './website-static-images.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule],
|
imports: [AuthModule, TenantModule],
|
||||||
@@ -30,12 +35,15 @@ import { WebsiteSlidersService } from './website-sliders.service';
|
|||||||
WebsiteBrandGroupsController,
|
WebsiteBrandGroupsController,
|
||||||
PublicWebsiteSlidersController,
|
PublicWebsiteSlidersController,
|
||||||
WebsiteSlidersController,
|
WebsiteSlidersController,
|
||||||
|
PublicWebsiteStaticImagesController,
|
||||||
|
WebsiteStaticImagesController,
|
||||||
PublicWebsiteBusinessInfoController,
|
PublicWebsiteBusinessInfoController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WebsiteCategoryGroupsService,
|
WebsiteCategoryGroupsService,
|
||||||
WebsiteBrandGroupsService,
|
WebsiteBrandGroupsService,
|
||||||
WebsiteSlidersService,
|
WebsiteSlidersService,
|
||||||
|
WebsiteStaticImagesService,
|
||||||
WebsiteBusinessInfoService,
|
WebsiteBusinessInfoService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user