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