Finish admin page

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent cda5d21d0d
commit 8fd23d162f
107 changed files with 6321 additions and 563 deletions
@@ -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,
};
}
}