Finish admin page
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user