Sync static image slots from the live website catalog.
Refresh now imports keys from the storefront and deletes unused slots plus their files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1c7678ce67
commit
5e5bfc4aa3
@@ -267,6 +267,7 @@ All routes are prefixed with `/api/v1`.
|
||||
| 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 |
|
||||
| POST | `/businesses/:id/website/static-images/sync` | Import slot keys from `GET https://{domain}/meshkee/static-image-slots` |
|
||||
|
||||
### Authenticated (JWT)
|
||||
|
||||
|
||||
@@ -54,6 +54,37 @@ Named slots the business dashboard can replace. Fetch once per page:
|
||||
|
||||
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.
|
||||
|
||||
**Also publish a slot catalog on this website** so the business dashboard Refresh button can import keys from the main domain:
|
||||
|
||||
`GET https://<WEBSITE_DOMAIN>/meshkee/static-image-slots`
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": [
|
||||
{
|
||||
"key": "slider",
|
||||
"label": "Homepage slider",
|
||||
"kind": "list",
|
||||
"pageKey": "home",
|
||||
"aspectRatio": "16:9",
|
||||
"itemCount": null,
|
||||
"recommendedWidth": 1440
|
||||
},
|
||||
{
|
||||
"key": "home-side-banner",
|
||||
"label": "Side banner",
|
||||
"kind": "single",
|
||||
"pageKey": "home",
|
||||
"aspectRatio": "12:19",
|
||||
"itemCount": 1,
|
||||
"recommendedWidth": 480
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs.
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,6 +184,126 @@ export class MediaService {
|
||||
return { message: 'Media deleted' };
|
||||
}
|
||||
|
||||
/** Delete media that is no longer referenced by any business record. */
|
||||
async deleteUnused(businessId: bigint, mediaIds: bigint[]): Promise<number> {
|
||||
if (!mediaIds.length) return 0;
|
||||
|
||||
const uniqueIds = [...new Set(mediaIds.map((id) => id.toString()))].map(
|
||||
(id) => BigInt(id),
|
||||
);
|
||||
const stillLinked = new Set<string>();
|
||||
|
||||
const addId = (id: bigint | null | undefined) => {
|
||||
if (id != null) stillLinked.add(id.toString());
|
||||
};
|
||||
|
||||
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||
where: { businessId, mediaId: { in: uniqueIds } },
|
||||
select: { mediaId: true },
|
||||
});
|
||||
for (const row of attachments) addId(row.mediaId);
|
||||
|
||||
const [
|
||||
portfolios,
|
||||
products,
|
||||
blogs,
|
||||
videos,
|
||||
instructions,
|
||||
brands,
|
||||
userProducts,
|
||||
sliders,
|
||||
staticItems,
|
||||
business,
|
||||
] = await Promise.all([
|
||||
this.prisma.portfolios.findMany({
|
||||
where: { business_id: businessId, featured_media_id: { in: uniqueIds } },
|
||||
select: { featured_media_id: true },
|
||||
}),
|
||||
this.prisma.product.findMany({
|
||||
where: { businessId, featuredMediaId: { in: uniqueIds } },
|
||||
select: { featuredMediaId: true },
|
||||
}),
|
||||
this.prisma.blogs.findMany({
|
||||
where: { business_id: businessId, featured_media_id: { in: uniqueIds } },
|
||||
select: { featured_media_id: true },
|
||||
}),
|
||||
this.prisma.videos.findMany({
|
||||
where: { business_id: businessId, featured_media_id: { in: uniqueIds } },
|
||||
select: { featured_media_id: true },
|
||||
}),
|
||||
this.prisma.instructions.findMany({
|
||||
where: { business_id: businessId, featured_media_id: { in: uniqueIds } },
|
||||
select: { featured_media_id: true },
|
||||
}),
|
||||
this.prisma.brand.findMany({
|
||||
where: { businessId, imageMediaId: { in: uniqueIds } },
|
||||
select: { imageMediaId: true },
|
||||
}),
|
||||
this.prisma.userProduct.findMany({
|
||||
where: { businessId, featuredMediaId: { in: uniqueIds } },
|
||||
select: { featuredMediaId: true },
|
||||
}),
|
||||
this.prisma.website_slider_slides.findMany({
|
||||
where: {
|
||||
image_media_id: { in: uniqueIds },
|
||||
website_sliders: { business_id: businessId },
|
||||
},
|
||||
select: { image_media_id: true },
|
||||
}),
|
||||
this.prisma.website_image_slot_items.findMany({
|
||||
where: {
|
||||
image_media_id: { in: uniqueIds },
|
||||
website_image_slots: { business_id: businessId },
|
||||
},
|
||||
select: { image_media_id: true },
|
||||
}),
|
||||
this.prisma.business.findFirst({
|
||||
where: {
|
||||
id: businessId,
|
||||
OR: [
|
||||
{ logoMediaId: { in: uniqueIds } },
|
||||
{ faviconMediaId: { in: uniqueIds } },
|
||||
],
|
||||
},
|
||||
select: { logoMediaId: true, faviconMediaId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const row of portfolios) addId(row.featured_media_id);
|
||||
for (const row of products) addId(row.featuredMediaId);
|
||||
for (const row of blogs) addId(row.featured_media_id);
|
||||
for (const row of videos) addId(row.featured_media_id);
|
||||
for (const row of instructions) addId(row.featured_media_id);
|
||||
for (const row of brands) addId(row.imageMediaId);
|
||||
for (const row of userProducts) addId(row.featuredMediaId);
|
||||
for (const row of sliders) addId(row.image_media_id);
|
||||
for (const row of staticItems) addId(row.image_media_id);
|
||||
addId(business?.logoMediaId);
|
||||
addId(business?.faviconMediaId);
|
||||
|
||||
const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString()));
|
||||
if (!deletableIds.length) return 0;
|
||||
|
||||
const mediaRows = await this.prisma.media.findMany({
|
||||
where: { businessId, id: { in: deletableIds } },
|
||||
select: { id: true, storagePath: true, storageDisk: true },
|
||||
});
|
||||
|
||||
await this.prisma.media.deleteMany({
|
||||
where: { businessId, id: { in: mediaRows.map((row) => row.id) } },
|
||||
});
|
||||
|
||||
for (const row of mediaRows) {
|
||||
try {
|
||||
await this.storage.delete(row.storagePath, row.storageDisk);
|
||||
} catch {
|
||||
// DB row removed; orphaned object can be cleaned later
|
||||
}
|
||||
}
|
||||
|
||||
return mediaRows.length;
|
||||
}
|
||||
|
||||
private async uploadOne(
|
||||
businessId: bigint,
|
||||
file: Express.Multer.File,
|
||||
|
||||
@@ -54,6 +54,37 @@ Named slots the business dashboard can replace. Fetch once per page:
|
||||
|
||||
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.
|
||||
|
||||
**Also publish a slot catalog on this website** so the business dashboard Refresh button can import keys from the main domain:
|
||||
|
||||
`GET https://<WEBSITE_DOMAIN>/meshkee/static-image-slots`
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": [
|
||||
{
|
||||
"key": "slider",
|
||||
"label": "Homepage slider",
|
||||
"kind": "list",
|
||||
"pageKey": "home",
|
||||
"aspectRatio": "16:9",
|
||||
"itemCount": null,
|
||||
"recommendedWidth": 1440
|
||||
},
|
||||
{
|
||||
"key": "home-side-banner",
|
||||
"label": "Side banner",
|
||||
"kind": "single",
|
||||
"pageKey": "home",
|
||||
"aspectRatio": "12:19",
|
||||
"itemCount": 1,
|
||||
"recommendedWidth": 480
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs.
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { SPECIAL_GROUP_KEY_PATTERN } from '../common/special-group-key';
|
||||
|
||||
export const WEBSITE_STATIC_IMAGE_CATALOG_PATH = '/meshkee/static-image-slots';
|
||||
|
||||
export type WebsiteStaticImageCatalogSlot = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'single' | 'list';
|
||||
pageKey: string;
|
||||
aspectRatio: string | null;
|
||||
recommendedWidth: number | null;
|
||||
itemCount: number | null;
|
||||
};
|
||||
|
||||
const ASPECT_RATIO_PATTERN = /^[0-9]{1,3}:[0-9]{1,3}$/;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readOptionalInt(value: unknown, min: number, max: number): number | null {
|
||||
if (value == null || value === '') return null;
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseWebsiteStaticImageCatalog(
|
||||
payload: unknown,
|
||||
): WebsiteStaticImageCatalogSlot[] {
|
||||
const root = asRecord(payload);
|
||||
const rawSlots = Array.isArray(root?.slots) ? root.slots : Array.isArray(payload) ? payload : null;
|
||||
if (!rawSlots) {
|
||||
throw new Error('Slot catalog must be { slots: [...] }');
|
||||
}
|
||||
|
||||
const slots: WebsiteStaticImageCatalogSlot[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of rawSlots) {
|
||||
const row = asRecord(entry);
|
||||
if (!row) continue;
|
||||
|
||||
const key = readString(row.key);
|
||||
if (!key || !SPECIAL_GROUP_KEY_PATTERN.test(key) || seen.has(key)) continue;
|
||||
|
||||
const kind = readString(row.kind) === 'single' ? 'single' : 'list';
|
||||
const label = readString(row.label) || key;
|
||||
const pageKey = readString(row.pageKey) || 'home';
|
||||
if (!SPECIAL_GROUP_KEY_PATTERN.test(pageKey)) continue;
|
||||
|
||||
const aspectRatioRaw = readString(row.aspectRatio);
|
||||
const aspectRatio =
|
||||
aspectRatioRaw && ASPECT_RATIO_PATTERN.test(aspectRatioRaw) ? aspectRatioRaw : null;
|
||||
|
||||
const itemCount =
|
||||
kind === 'single' ? 1 : readOptionalInt(row.itemCount, 1, 24);
|
||||
|
||||
seen.add(key);
|
||||
slots.push({
|
||||
key,
|
||||
label: label.slice(0, 255),
|
||||
kind,
|
||||
pageKey,
|
||||
aspectRatio,
|
||||
recommendedWidth: readOptionalInt(row.recommendedWidth, 1, 8000),
|
||||
itemCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (slots.length === 0) {
|
||||
throw new Error('Slot catalog did not contain any valid slots');
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function websiteCatalogUrls(hosts: string[]): string[] {
|
||||
const uniqueHosts = [...new Set(hosts.map((host) => host.toLowerCase().trim()).filter(Boolean))];
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const host of uniqueHosts) {
|
||||
const bare = host.replace(/^www\./, '');
|
||||
const variants = [bare, `www.${bare}`];
|
||||
for (const candidate of variants) {
|
||||
urls.push(`https://${candidate}${WEBSITE_STATIC_IMAGE_CATALOG_PATH}`);
|
||||
urls.push(`http://${candidate}${WEBSITE_STATIC_IMAGE_CATALOG_PATH}`);
|
||||
urls.push(`https://${candidate}${WEBSITE_STATIC_IMAGE_CATALOG_PATH}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
@@ -55,6 +55,15 @@ export class WebsiteStaticImagesController {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post('sync')
|
||||
@RequireBusinessPermission('website.update')
|
||||
syncFromWebsite(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.syncFromWebsite(businessId, user);
|
||||
}
|
||||
|
||||
@Get(':slotId')
|
||||
@RequireBusinessPermission('website.read')
|
||||
getOne(
|
||||
|
||||
@@ -9,8 +9,14 @@ 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 { MediaService } from '../media/media.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
parseWebsiteStaticImageCatalog,
|
||||
WEBSITE_STATIC_IMAGE_CATALOG_PATH,
|
||||
websiteCatalogUrls,
|
||||
} from './website-static-image-catalog';
|
||||
import {
|
||||
CreateWebsiteStaticImageSlotDto,
|
||||
ListWebsiteStaticImageSlotsDto,
|
||||
@@ -51,6 +57,7 @@ export class WebsiteStaticImagesService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly media: MediaService,
|
||||
) {}
|
||||
|
||||
async listPublic(host: string, pageKey?: string) {
|
||||
@@ -172,6 +179,106 @@ export class WebsiteStaticImagesService {
|
||||
};
|
||||
}
|
||||
|
||||
async syncFromWebsite(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
const hosts = await this.listWebsiteHosts(businessId);
|
||||
if (hosts.length === 0) {
|
||||
throw new BadRequestException('This business has no website domain to read slots from');
|
||||
}
|
||||
|
||||
const catalog = await this.fetchWebsiteCatalog(hosts);
|
||||
const existing = await this.prisma.website_image_slots.findMany({
|
||||
where: { business_id: businessId },
|
||||
include: slotInclude,
|
||||
});
|
||||
const existingByKey = new Map(existing.map((slot) => [slot.slot_key, slot]));
|
||||
const catalogKeys = new Set(catalog.map((slot) => slot.key));
|
||||
const stale = existing.filter((slot) => !catalogKeys.has(slot.slot_key));
|
||||
const staleMediaIds = stale.flatMap((slot) =>
|
||||
slot.website_image_slot_items.map((item) => item.image_media_id),
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
const deleted = stale.length;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const [index, slot] of catalog.entries()) {
|
||||
const current = existingByKey.get(slot.key);
|
||||
const itemCount = resolveItemCount(slot.kind, slot.itemCount);
|
||||
|
||||
if (!current) {
|
||||
await tx.website_image_slots.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
slot_key: slot.key,
|
||||
kind: slot.kind,
|
||||
label: slot.label,
|
||||
aspect_ratio: slot.aspectRatio,
|
||||
recommended_width: slot.recommendedWidth,
|
||||
item_count: itemCount,
|
||||
page_key: resolvePageKey(slot.pageKey),
|
||||
sort_order: index,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageCount = current.website_image_slot_items.length;
|
||||
const nextKind =
|
||||
slot.kind === 'single' && imageCount > 1 ? current.kind : slot.kind;
|
||||
const nextItemCount =
|
||||
nextKind === 'single'
|
||||
? 1
|
||||
: itemCount != null && imageCount > itemCount
|
||||
? current.item_count
|
||||
: itemCount;
|
||||
|
||||
await tx.website_image_slots.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
kind: nextKind,
|
||||
label: slot.label,
|
||||
aspect_ratio: slot.aspectRatio,
|
||||
recommended_width: slot.recommendedWidth,
|
||||
item_count: nextItemCount,
|
||||
page_key: resolvePageKey(slot.pageKey),
|
||||
sort_order: index,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
await tx.website_image_slots.deleteMany({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
id: { in: stale.map((slot) => slot.id) },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.media.deleteUnused(businessId, staleMediaIds);
|
||||
|
||||
const listed = await this.prisma.website_image_slots.findMany({
|
||||
where: { business_id: businessId },
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
include: slotInclude,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Static image slots refreshed from the website',
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
items: listed.map((slot) => this.serializeSlot(slot, false)),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
slotIdRaw: string,
|
||||
@@ -215,6 +322,17 @@ export class WebsiteStaticImagesService {
|
||||
);
|
||||
}
|
||||
|
||||
const previousMediaIds =
|
||||
dto.items !== undefined
|
||||
? existing.website_image_slot_items.map((item) => item.image_media_id)
|
||||
: [];
|
||||
const nextMediaIds = new Set(
|
||||
(dto.items ?? []).map((item) => item.imageMediaId),
|
||||
);
|
||||
const removedMediaIds = previousMediaIds.filter(
|
||||
(id) => !nextMediaIds.has(id.toString()),
|
||||
);
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.website_image_slots.update({
|
||||
where: { id: slotId },
|
||||
@@ -248,6 +366,8 @@ export class WebsiteStaticImagesService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.media.deleteUnused(businessId, removedMediaIds);
|
||||
|
||||
return {
|
||||
message: 'Static image slot updated successfully',
|
||||
slot: this.serializeSlot(updated, false),
|
||||
@@ -259,8 +379,12 @@ export class WebsiteStaticImagesService {
|
||||
const slotId = BigInt(slotIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findSlotOrThrow(businessId, slotId);
|
||||
const existing = await this.findSlotOrThrow(businessId, slotId);
|
||||
const mediaIds = existing.website_image_slot_items.map(
|
||||
(item) => item.image_media_id,
|
||||
);
|
||||
await this.prisma.website_image_slots.delete({ where: { id: slotId } });
|
||||
await this.media.deleteUnused(businessId, mediaIds);
|
||||
|
||||
return { message: 'Static image slot deleted successfully' };
|
||||
}
|
||||
@@ -395,6 +519,62 @@ export class WebsiteStaticImagesService {
|
||||
};
|
||||
}
|
||||
|
||||
private async listWebsiteHosts(businessId: bigint): Promise<string[]> {
|
||||
const domains = await this.prisma.domain.findMany({
|
||||
where: { businessId, isActive: true },
|
||||
orderBy: [{ isPrimary: 'desc' }, { host: 'asc' }],
|
||||
select: { host: true },
|
||||
});
|
||||
|
||||
return domains
|
||||
.map((domain) => domain.host.toLowerCase().trim())
|
||||
.filter(
|
||||
(host) =>
|
||||
host &&
|
||||
!host.startsWith('business.') &&
|
||||
!host.startsWith('customer.'),
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchWebsiteCatalog(hosts: string[]) {
|
||||
const urls = websiteCatalogUrls(hosts);
|
||||
let lastError = 'Website slot catalog was not found';
|
||||
|
||||
for (const url of urls) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Meshkee-CMS/1.0',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
lastError = `Website returned ${response.status} for ${WEBSITE_STATIC_IMAGE_CATALOG_PATH}`;
|
||||
continue;
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
return parseWebsiteStaticImageCatalog(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Slot catalog')) {
|
||||
lastError = error.message;
|
||||
continue;
|
||||
}
|
||||
lastError = `Could not read ${WEBSITE_STATIC_IMAGE_CATALOG_PATH} from the website`;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`${lastError}. The site must publish GET ${WEBSITE_STATIC_IMAGE_CATALOG_PATH} on its main domain.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import {
|
||||
PublicWebsiteBrandGroupsController,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
import { WebsiteStaticImagesService } from './website-static-images.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
imports: [AuthModule, TenantModule, MediaModule],
|
||||
controllers: [
|
||||
PublicWebsiteCategoryGroupsController,
|
||||
WebsiteCategoryGroupsController,
|
||||
|
||||
Reference in New Issue
Block a user