248 lines
8.1 KiB
TypeScript
248 lines
8.1 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
import { LOCALES, type AdminCollection, type OffsetPaginated } from '@sport/types';
|
|
import type { CollectionInput, TaxonomyListQuery } from '@sport/validation';
|
|
|
|
import { AuditService } from '@/common/audit/audit.service';
|
|
import { AppException } from '@/common/errors/app.exception';
|
|
import { toDbLocale } from '@/common/i18n';
|
|
import { MediaUrlService } from '@/common/media/media-url.service';
|
|
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
|
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
|
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
|
|
|
const select = {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
description: true,
|
|
type: true,
|
|
bannerId: true,
|
|
position: true,
|
|
startsAt: true,
|
|
endsAt: true,
|
|
isActive: true,
|
|
metaTitle: true,
|
|
metaDescription: true,
|
|
banner: { select: { storageKey: true } },
|
|
translations: true,
|
|
_count: { select: { products: true } },
|
|
} as const;
|
|
|
|
type Row = Prisma.CollectionGetPayload<{ select: typeof select }>;
|
|
|
|
@Injectable()
|
|
export class CollectionsAdminService {
|
|
private readonly logger = new Logger(CollectionsAdminService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly audit: AuditService,
|
|
private readonly mediaUrl: MediaUrlService,
|
|
private readonly redis: RedisService,
|
|
) {}
|
|
|
|
async list(query: TaxonomyListQuery): Promise<OffsetPaginated<AdminCollection>> {
|
|
const where: Prisma.CollectionWhereInput = {
|
|
...(query.activeOnly ? { isActive: true } : {}),
|
|
...(query.q ? { name: { contains: query.q, mode: 'insensitive' } } : {}),
|
|
};
|
|
|
|
const [rows, totalItems] = await Promise.all([
|
|
this.prisma.collection.findMany({
|
|
where,
|
|
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
|
skip: (query.page - 1) * query.perPage,
|
|
take: query.perPage,
|
|
select,
|
|
}),
|
|
this.prisma.collection.count({ where }),
|
|
]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
|
|
|
return {
|
|
items: rows.map((row) => this.toAdmin(row)),
|
|
pageInfo: {
|
|
page: query.page,
|
|
perPage: query.perPage,
|
|
totalItems,
|
|
totalPages,
|
|
hasNextPage: query.page < totalPages,
|
|
},
|
|
};
|
|
}
|
|
|
|
async getById(id: string): Promise<AdminCollection> {
|
|
const row = await this.prisma.collection.findUnique({ where: { id }, select });
|
|
if (!row) throw AppException.notFound('Collection');
|
|
|
|
return this.toAdmin(row);
|
|
}
|
|
|
|
async create(input: CollectionInput, actorUserId: string): Promise<AdminCollection> {
|
|
await this.assertSlugFree(input.slug, null);
|
|
|
|
const created = await this.prisma.$transaction(async (tx) => {
|
|
const brand = await tx.collection.create({
|
|
data: {
|
|
name: input.name,
|
|
slug: input.slug,
|
|
description: input.description ?? null,
|
|
type: input.type,
|
|
bannerId: input.bannerId ?? null,
|
|
position: input.position,
|
|
startsAt: input.startsAt ? new Date(input.startsAt) : null,
|
|
endsAt: input.endsAt ? new Date(input.endsAt) : null,
|
|
isActive: input.isActive,
|
|
metaTitle: input.metaTitle ?? null,
|
|
metaDescription: input.metaDescription ?? null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
await this.writeTranslations(tx, brand.id, input);
|
|
return brand;
|
|
});
|
|
|
|
await this.afterWrite(actorUserId, 'collection.create', created.id, { slug: input.slug });
|
|
return this.getById(created.id);
|
|
}
|
|
|
|
async update(id: string, input: CollectionInput, actorUserId: string): Promise<AdminCollection> {
|
|
const existing = await this.prisma.collection.findUnique({
|
|
where: { id },
|
|
select: { id: true },
|
|
});
|
|
if (!existing) throw AppException.notFound('Collection');
|
|
|
|
await this.assertSlugFree(input.slug, id);
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.collection.update({
|
|
where: { id },
|
|
data: {
|
|
name: input.name,
|
|
slug: input.slug,
|
|
description: input.description ?? null,
|
|
type: input.type,
|
|
bannerId: input.bannerId ?? null,
|
|
position: input.position,
|
|
startsAt: input.startsAt ? new Date(input.startsAt) : null,
|
|
endsAt: input.endsAt ? new Date(input.endsAt) : null,
|
|
isActive: input.isActive,
|
|
metaTitle: input.metaTitle ?? null,
|
|
metaDescription: input.metaDescription ?? null,
|
|
},
|
|
});
|
|
|
|
await this.writeTranslations(tx, id, input);
|
|
});
|
|
|
|
await this.afterWrite(actorUserId, 'collection.update', id, { slug: input.slug });
|
|
return this.getById(id);
|
|
}
|
|
|
|
/**
|
|
* Deleting a collection is safe, unlike a brand or a category.
|
|
*
|
|
* `ProductCollection` cascades, so removing the collection removes only the
|
|
* membership rows — the products themselves are untouched and keep their
|
|
* category and brand. The product count is still surfaced in the UI so the
|
|
* operator knows how many items are about to lose this grouping.
|
|
*/
|
|
async remove(id: string, actorUserId: string): Promise<void> {
|
|
const row = await this.prisma.collection.findUnique({
|
|
where: { id },
|
|
select: { slug: true, _count: { select: { products: true } } },
|
|
});
|
|
if (!row) throw AppException.notFound('Collection');
|
|
|
|
await this.prisma.collection.delete({ where: { id } });
|
|
await this.afterWrite(actorUserId, 'collection.delete', id, { slug: row.slug });
|
|
}
|
|
|
|
// ---- internals -----------------------------------------------------------
|
|
|
|
private async assertSlugFree(slug: string, exceptId: string | null): Promise<void> {
|
|
const clash = await this.prisma.collection.findFirst({
|
|
where: { slug, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (clash)
|
|
throw AppException.conflict(`The slug "${slug}" is already used by another collection.`);
|
|
}
|
|
|
|
private async writeTranslations(
|
|
tx: Prisma.TransactionClient,
|
|
collectionId: string,
|
|
input: CollectionInput,
|
|
): Promise<void> {
|
|
for (const locale of LOCALES) {
|
|
const fields = input.translations[locale];
|
|
if (!fields) continue;
|
|
|
|
const data = {
|
|
name: fields.name,
|
|
slug: fields.slug,
|
|
description: fields.description ?? null,
|
|
metaTitle: fields.metaTitle ?? null,
|
|
metaDescription: fields.metaDescription ?? null,
|
|
};
|
|
|
|
await tx.collectionTranslation.upsert({
|
|
where: { collectionId_locale: { collectionId, locale: toDbLocale(locale) } },
|
|
update: data,
|
|
create: { collectionId, locale: toDbLocale(locale), ...data },
|
|
});
|
|
}
|
|
}
|
|
|
|
private async afterWrite(
|
|
actorUserId: string,
|
|
action: string,
|
|
resourceId: string,
|
|
changes: Prisma.InputJsonValue,
|
|
): Promise<void> {
|
|
// Collections back /collections/* pages and the footer/nav links, all of
|
|
// which are cached — see ProductsAdminService.afterWrite.
|
|
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
|
|
this.logger.log(`${action} on ${resourceId}; dropped ${dropped} catalog cache key(s)`);
|
|
|
|
this.audit.record({ actorUserId, action, resourceType: 'Collection', resourceId, changes });
|
|
}
|
|
|
|
private toAdmin(row: Row): AdminCollection {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
slug: row.slug,
|
|
description: row.description,
|
|
type: row.type,
|
|
bannerId: row.bannerId,
|
|
bannerUrl: row.banner ? this.mediaUrl.url(row.banner.storageKey) : null,
|
|
position: row.position,
|
|
startsAt: row.startsAt?.toISOString() ?? null,
|
|
endsAt: row.endsAt?.toISOString() ?? null,
|
|
isActive: row.isActive,
|
|
metaTitle: row.metaTitle,
|
|
metaDescription: row.metaDescription,
|
|
translations: Object.fromEntries(
|
|
row.translations.map((translation) => [
|
|
translation.locale.toLowerCase(),
|
|
{
|
|
name: translation.name,
|
|
slug: translation.slug,
|
|
description: translation.description,
|
|
metaTitle: translation.metaTitle,
|
|
metaDescription: translation.metaDescription,
|
|
},
|
|
]),
|
|
),
|
|
productCount: row._count.products,
|
|
};
|
|
}
|
|
}
|