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,77 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminBrand,
type AuthenticatedActor,
type OffsetPaginated,
} from '@sport/types';
import {
brandInputSchema,
taxonomyListQuerySchema,
type BrandInput,
type TaxonomyListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { BrandsAdminService } from './brands-admin.service';
@ApiTags('admin/brands')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/brands')
export class BrandsAdminController {
constructor(private readonly service: BrandsAdminService) {}
@Get()
@RequirePermissions(PERMISSIONS.BRAND_READ)
@ApiOperation({ summary: 'All brands, including inactive' })
list(
@Query(new ZodValidationPipe(taxonomyListQuerySchema)) query: TaxonomyListQuery,
): Promise<OffsetPaginated<AdminBrand>> {
return this.service.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.BRAND_READ)
@ApiOperation({ summary: 'One entry, every locale' })
getById(@Param('id') id: string): Promise<AdminBrand> {
return this.service.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.BRAND_MANAGE)
@ApiOperation({ summary: 'Create' })
create(
@Body(new ZodValidationPipe(brandInputSchema)) body: BrandInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminBrand> {
return this.service.create(body, actor.userId);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.BRAND_MANAGE)
@ApiOperation({ summary: 'Update' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(brandInputSchema)) body: BrandInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminBrand> {
return this.service.update(id, body, actor.userId);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.BRAND_MANAGE)
@ApiOperation({ summary: 'Delete, if nothing depends on it' })
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.service.remove(id, actor.userId);
}
}
@@ -0,0 +1,233 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { LOCALES, type AdminBrand, type OffsetPaginated } from '@sport/types';
import type { BrandInput, 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,
logoId: true,
isActive: true,
metaTitle: true,
metaDescription: true,
logo: { select: { storageKey: true } },
translations: true,
_count: { select: { products: true } },
} as const;
type Row = Prisma.BrandGetPayload<{ select: typeof select }>;
@Injectable()
export class BrandsAdminService {
private readonly logger = new Logger(BrandsAdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly mediaUrl: MediaUrlService,
private readonly redis: RedisService,
) {}
async list(query: TaxonomyListQuery): Promise<OffsetPaginated<AdminBrand>> {
const where: Prisma.BrandWhereInput = {
...(query.activeOnly ? { isActive: true } : {}),
...(query.q ? { name: { contains: query.q, mode: 'insensitive' } } : {}),
};
const [rows, totalItems] = await Promise.all([
this.prisma.brand.findMany({
where,
orderBy: [{ name: 'asc' }],
skip: (query.page - 1) * query.perPage,
take: query.perPage,
select,
}),
this.prisma.brand.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<AdminBrand> {
const row = await this.prisma.brand.findUnique({ where: { id }, select });
if (!row) throw AppException.notFound('Brand');
return this.toAdmin(row);
}
async create(input: BrandInput, actorUserId: string): Promise<AdminBrand> {
await this.assertSlugFree(input.slug, null);
const created = await this.prisma.$transaction(async (tx) => {
const brand = await tx.brand.create({
data: {
name: input.name,
slug: input.slug,
description: input.description ?? null,
logoId: input.logoId ?? 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, 'brand.create', created.id, { slug: input.slug });
return this.getById(created.id);
}
async update(id: string, input: BrandInput, actorUserId: string): Promise<AdminBrand> {
const existing = await this.prisma.brand.findUnique({ where: { id }, select: { id: true } });
if (!existing) throw AppException.notFound('Brand');
await this.assertSlugFree(input.slug, id);
await this.prisma.$transaction(async (tx) => {
await tx.brand.update({
where: { id },
data: {
name: input.name,
slug: input.slug,
description: input.description ?? null,
logoId: input.logoId ?? null,
isActive: input.isActive,
metaTitle: input.metaTitle ?? null,
metaDescription: input.metaDescription ?? null,
},
});
await this.writeTranslations(tx, id, input);
});
await this.afterWrite(actorUserId, 'brand.update', id, { slug: input.slug });
return this.getById(id);
}
/**
* Refused while products still point here.
*
* `Product.brand` is `onDelete: SetNull`, so the database would happily
* delete this and quietly strip the brand from every product that used it.
* That is data loss with no warning, which is exactly why the check is here
* rather than left to the schema.
*/
async remove(id: string, actorUserId: string): Promise<void> {
const row = await this.prisma.brand.findUnique({
where: { id },
select: { slug: true, _count: { select: { products: true } } },
});
if (!row) throw AppException.notFound('Brand');
if (row._count.products > 0) {
throw AppException.conflict(
`${row._count.products} product(s) still use this brand. Reassign them first.`,
);
}
await this.prisma.brand.delete({ where: { id } });
await this.afterWrite(actorUserId, 'brand.delete', id, { slug: row.slug });
}
// ---- internals -----------------------------------------------------------
private async assertSlugFree(slug: string, exceptId: string | null): Promise<void> {
const clash = await this.prisma.brand.findFirst({
where: { slug, ...(exceptId ? { id: { not: exceptId } } : {}) },
select: { id: true },
});
if (clash) throw AppException.conflict(`The slug "${slug}" is already used by another brand.`);
}
private async writeTranslations(
tx: Prisma.TransactionClient,
brandId: string,
input: BrandInput,
): 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.brandTranslation.upsert({
where: { brandId_locale: { brandId, locale: toDbLocale(locale) } },
update: data,
create: { brandId, locale: toDbLocale(locale), ...data },
});
}
}
private async afterWrite(
actorUserId: string,
action: string,
resourceId: string,
changes: Prisma.InputJsonValue,
): Promise<void> {
// Brands appear on product cards and in the listing facet rail, both 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: 'Brand', resourceId, changes });
}
private toAdmin(row: Row): AdminBrand {
return {
id: row.id,
name: row.name,
slug: row.slug,
description: row.description,
logoId: row.logoId,
logoUrl: row.logo ? this.mediaUrl.url(row.logo.storageKey) : 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,
};
}
}
+7 -2
View File
@@ -1,5 +1,9 @@
import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module';
import { BrandsAdminController } from './brands-admin.controller';
import { BrandsAdminService } from './brands-admin.service';
import { BrandsController } from './brands.controller';
import { BrandsMapper } from './brands.mapper';
import { BrandsRepository } from './brands.repository';
@@ -12,8 +16,9 @@ import { BrandsService } from './brands.service';
* brand filter facet and (later) brand-level commercial terms all hang off it.
*/
@Module({
controllers: [BrandsController],
providers: [BrandsService, BrandsRepository, BrandsMapper],
imports: [MediaUrlModule],
controllers: [BrandsController, BrandsAdminController],
providers: [BrandsService, BrandsRepository, BrandsMapper, BrandsAdminService],
exports: [BrandsService],
})
export class BrandsModule {}
@@ -0,0 +1,77 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminCategory,
type AuthenticatedActor,
type OffsetPaginated,
} from '@sport/types';
import {
categoryInputSchema,
taxonomyListQuerySchema,
type CategoryInput,
type TaxonomyListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { CategoriesAdminService } from './categories-admin.service';
@ApiTags('admin/categories')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/categories')
export class CategoriesAdminController {
constructor(private readonly service: CategoriesAdminService) {}
@Get()
@RequirePermissions(PERMISSIONS.CATEGORY_READ)
@ApiOperation({ summary: 'All categories, including inactive' })
list(
@Query(new ZodValidationPipe(taxonomyListQuerySchema)) query: TaxonomyListQuery,
): Promise<OffsetPaginated<AdminCategory>> {
return this.service.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.CATEGORY_READ)
@ApiOperation({ summary: 'One entry, every locale' })
getById(@Param('id') id: string): Promise<AdminCategory> {
return this.service.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.CATEGORY_MANAGE)
@ApiOperation({ summary: 'Create' })
create(
@Body(new ZodValidationPipe(categoryInputSchema)) body: CategoryInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminCategory> {
return this.service.create(body, actor.userId);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.CATEGORY_MANAGE)
@ApiOperation({ summary: 'Update' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(categoryInputSchema)) body: CategoryInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminCategory> {
return this.service.update(id, body, actor.userId);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.CATEGORY_MANAGE)
@ApiOperation({ summary: 'Delete, if nothing depends on it' })
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.service.remove(id, actor.userId);
}
}
@@ -0,0 +1,340 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { LOCALES, type AdminCategory, type OffsetPaginated } from '@sport/types';
import type { CategoryInput, 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,
parentId: true,
name: true,
slug: true,
path: true,
depth: true,
position: true,
description: true,
imageId: true,
isActive: true,
metaTitle: true,
metaDescription: true,
image: { select: { storageKey: true } },
translations: true,
_count: { select: { products: true, children: true } },
} as const;
type Row = Prisma.CategoryGetPayload<{ select: typeof select }>;
@Injectable()
export class CategoriesAdminService {
private readonly logger = new Logger(CategoriesAdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly mediaUrl: MediaUrlService,
private readonly redis: RedisService,
) {}
async list(query: TaxonomyListQuery): Promise<OffsetPaginated<AdminCategory>> {
const where: Prisma.CategoryWhereInput = {
...(query.activeOnly ? { isActive: true } : {}),
...(query.q ? { name: { contains: query.q, mode: 'insensitive' } } : {}),
};
const [rows, totalItems] = await Promise.all([
this.prisma.category.findMany({
where,
// Tree order: the materialised path sorts parents immediately before
// their children, so the flat list reads as an indented hierarchy
// without a second pass to nest it.
orderBy: [{ path: 'asc' }],
skip: (query.page - 1) * query.perPage,
take: query.perPage,
select,
}),
this.prisma.category.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<AdminCategory> {
const row = await this.prisma.category.findUnique({ where: { id }, select });
if (!row) throw AppException.notFound('Category');
return this.toAdmin(row);
}
async create(input: CategoryInput, actorUserId: string): Promise<AdminCategory> {
const parent = await this.resolveParent(input.parentId ?? null);
const path = parent ? `${parent.path}/${input.slug}` : input.slug;
await this.assertPathFree(path, null);
const created = await this.prisma.$transaction(async (tx) => {
const category = await tx.category.create({
data: {
parentId: parent?.id ?? null,
name: input.name,
slug: input.slug,
path,
depth: parent ? parent.depth + 1 : 0,
position: input.position,
description: input.description ?? null,
imageId: input.imageId ?? null,
isActive: input.isActive,
metaTitle: input.metaTitle ?? null,
metaDescription: input.metaDescription ?? null,
},
select: { id: true },
});
await this.writeTranslations(tx, category.id, input);
return category;
});
await this.afterWrite(actorUserId, 'category.create', created.id, { path });
return this.getById(created.id);
}
async update(id: string, input: CategoryInput, actorUserId: string): Promise<AdminCategory> {
const existing = await this.prisma.category.findUnique({
where: { id },
select: { id: true, path: true, parentId: true },
});
if (!existing) throw AppException.notFound('Category');
const parent = await this.resolveParent(input.parentId ?? null);
/**
* A category cannot be moved under itself or under one of its own
* descendants.
*
* Checked on the materialised path rather than by walking parents: a
* descendant's path always starts with this category's path, so one string
* comparison catches every depth. Without it the subtree detaches from the
* root entirely — the rows survive but nothing can reach them, and every
* product beneath becomes unbrowsable.
*/
if (parent && (parent.id === id || parent.path.startsWith(`${existing.path}/`))) {
throw AppException.conflict('A category cannot be moved inside itself.');
}
const path = parent ? `${parent.path}/${input.slug}` : input.slug;
await this.assertPathFree(path, id);
await this.prisma.$transaction(async (tx) => {
await tx.category.update({
where: { id },
data: {
parentId: parent?.id ?? null,
name: input.name,
slug: input.slug,
path,
depth: parent ? parent.depth + 1 : 0,
position: input.position,
description: input.description ?? null,
imageId: input.imageId ?? null,
isActive: input.isActive,
metaTitle: input.metaTitle ?? null,
metaDescription: input.metaDescription ?? null,
},
});
await this.writeTranslations(tx, id, input);
/**
* Rewrite every descendant's path and depth.
*
* The whole point of a materialised path is that reads are cheap; the
* cost is that a rename or a move has to pay for it here. Skipping this
* leaves children pointing at a path their parent no longer has, and the
* subtree query — `LIKE 'men/running%'` — silently returns nothing.
*
* One statement rather than a recursive walk: `replace` on the prefix,
* and the depth delta applied uniformly, because every descendant moves
* by exactly the same number of levels as the node itself.
*/
if (existing.path !== path) {
const depthDelta = path.split('/').length - existing.path.split('/').length;
/**
* The `::int` casts are load-bearing.
*
* Prisma binds every JS number as `bigint`, and Postgres has no
* `substring(varchar, bigint)` — the statement fails with 42883
* "function does not exist" rather than a type-coercion warning. The
* transaction rolls back, so the rename appears to do nothing.
*/
await tx.$executeRaw`
UPDATE categories
SET path = ${path} || substring(path from ${existing.path.length + 1}::int),
depth = depth + ${depthDelta}::int
WHERE path LIKE ${`${existing.path}/%`}
`;
this.logger.log(`Re-pathed descendants of ${existing.path} -> ${path}`);
}
});
await this.afterWrite(actorUserId, 'category.update', id, { path });
return this.getById(id);
}
/**
* Hard delete, refused while anything depends on it.
*
* `Category.parent` is `onDelete: Restrict`, so the database would refuse a
* category with children anyway — but with a foreign-key error that means
* nothing to an operator. These checks say which of the two reasons applies.
*/
async remove(id: string, actorUserId: string): Promise<void> {
const row = await this.prisma.category.findUnique({
where: { id },
select: { path: true, _count: { select: { products: true, children: true } } },
});
if (!row) throw AppException.notFound('Category');
if (row._count.children > 0) {
throw AppException.conflict(
'Move or delete the sub-categories first — this one still has children.',
);
}
if (row._count.products > 0) {
throw AppException.conflict(
`${row._count.products} product(s) still use this category. Reassign them first.`,
);
}
await this.prisma.category.delete({ where: { id } });
await this.afterWrite(actorUserId, 'category.delete', id, { path: row.path });
}
// ---- internals -----------------------------------------------------------
private async resolveParent(parentId: string | null) {
if (!parentId) return null;
const parent = await this.prisma.category.findUnique({
where: { id: parentId },
select: { id: true, path: true, depth: true },
});
if (!parent) throw AppException.badRequest('The chosen parent category does not exist.');
return parent;
}
/** `path` is unique in the database; this turns the violation into English. */
private async assertPathFree(path: string, exceptId: string | null): Promise<void> {
const clash = await this.prisma.category.findFirst({
where: { path, ...(exceptId ? { id: { not: exceptId } } : {}) },
select: { id: true },
});
if (clash) {
throw AppException.conflict(`Another category already sits at "${path}".`);
}
}
private async writeTranslations(
tx: Prisma.TransactionClient,
categoryId: string,
input: CategoryInput,
): 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.categoryTranslation.upsert({
where: { categoryId_locale: { categoryId, locale: toDbLocale(locale) } },
update: data,
create: { categoryId, locale: toDbLocale(locale), ...data },
});
}
}
/**
* Drop the catalog cache and record the change.
*
* The category tree backs the storefront's navigation menu, which is cached
* for fifteen minutes. Without this, renaming a category leaves the old label
* in the header until the TTL expires — see the same reasoning in
* ProductsAdminService.afterWrite and ReviewsService.moderate.
*/
private async afterWrite(
actorUserId: string,
action: string,
resourceId: string,
changes: Prisma.InputJsonValue,
): Promise<void> {
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: 'Category',
resourceId,
changes,
});
}
private toAdmin(row: Row): AdminCategory {
return {
id: row.id,
parentId: row.parentId,
name: row.name,
slug: row.slug,
path: row.path,
depth: row.depth,
position: row.position,
description: row.description,
imageId: row.imageId,
imageUrl: row.image ? this.mediaUrl.url(row.image.storageKey) : 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,
childCount: row._count.children,
};
}
}
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module';
import { CollectionsModule } from '@/modules/collections/collections.module';
import { CategoriesAdminController } from './categories-admin.controller';
import { CategoriesAdminService } from './categories-admin.service';
import { CategoriesController } from './categories.controller';
import { CategoriesMapper } from './categories.mapper';
import { CategoriesRepository } from './categories.repository';
@@ -17,9 +20,9 @@ import { CategoriesService } from './categories.service';
* graph rather than hidden behind an ambient global.
*/
@Module({
imports: [CollectionsModule],
controllers: [CategoriesController],
providers: [CategoriesService, CategoriesRepository, CategoriesMapper],
imports: [CollectionsModule, MediaUrlModule],
controllers: [CategoriesController, CategoriesAdminController],
providers: [CategoriesService, CategoriesRepository, CategoriesMapper, CategoriesAdminService],
exports: [CategoriesService],
})
export class CategoriesModule {}
@@ -0,0 +1,77 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminCollection,
type AuthenticatedActor,
type OffsetPaginated,
} from '@sport/types';
import {
collectionInputSchema,
taxonomyListQuerySchema,
type CollectionInput,
type TaxonomyListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { CollectionsAdminService } from './collections-admin.service';
@ApiTags('admin/collections')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/collections')
export class CollectionsAdminController {
constructor(private readonly service: CollectionsAdminService) {}
@Get()
@RequirePermissions(PERMISSIONS.COLLECTION_READ)
@ApiOperation({ summary: 'All collections, including inactive' })
list(
@Query(new ZodValidationPipe(taxonomyListQuerySchema)) query: TaxonomyListQuery,
): Promise<OffsetPaginated<AdminCollection>> {
return this.service.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.COLLECTION_READ)
@ApiOperation({ summary: 'One entry, every locale' })
getById(@Param('id') id: string): Promise<AdminCollection> {
return this.service.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.COLLECTION_MANAGE)
@ApiOperation({ summary: 'Create' })
create(
@Body(new ZodValidationPipe(collectionInputSchema)) body: CollectionInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminCollection> {
return this.service.create(body, actor.userId);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.COLLECTION_MANAGE)
@ApiOperation({ summary: 'Update' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(collectionInputSchema)) body: CollectionInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminCollection> {
return this.service.update(id, body, actor.userId);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.COLLECTION_MANAGE)
@ApiOperation({ summary: 'Delete, if nothing depends on it' })
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.service.remove(id, actor.userId);
}
}
@@ -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,
};
}
}
@@ -1,5 +1,9 @@
import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module';
import { CollectionsAdminController } from './collections-admin.controller';
import { CollectionsAdminService } from './collections-admin.service';
import { CollectionsController } from './collections.controller';
import { CollectionsMapper } from './collections.mapper';
import { CollectionsRepository } from './collections.repository';
@@ -12,8 +16,14 @@ import { CollectionsService } from './collections.service';
* Editorial and campaign groupings, including their scheduling window.
*/
@Module({
controllers: [CollectionsController],
providers: [CollectionsService, CollectionsRepository, CollectionsMapper],
imports: [MediaUrlModule],
controllers: [CollectionsController, CollectionsAdminController],
providers: [
CollectionsService,
CollectionsRepository,
CollectionsMapper,
CollectionsAdminService,
],
exports: [CollectionsService],
})
export class CollectionsModule {}