feat(ADM-018): completed feature
This commit is contained in:
127
project/src/modules/brands/api/brands.routes.ts
Normal file
127
project/src/modules/brands/api/brands.routes.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateBrand,
|
||||
GetBrandBySlug,
|
||||
ListBrands,
|
||||
UpdateBrand,
|
||||
} from '../application/brand-use-cases.js';
|
||||
import type { Brand } from '../domain/brand.js';
|
||||
import { BrandSlugAlreadyExistsError } from '../domain/errors.js';
|
||||
import { PgBrandRepository } from '../infrastructure/pg-brand-repository.js';
|
||||
|
||||
export interface BrandsRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const slugSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
const slugParamSchema = z.object({ slug: slugSchema });
|
||||
const idParamSchema = z.object({ id: z.uuid() });
|
||||
|
||||
const newBrandSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
slug: slugSchema,
|
||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
const brandPatchSchema = newBrandSchema
|
||||
.partial()
|
||||
.refine((value) => Object.values(value).some((field) => field !== undefined), {
|
||||
message: 'At least one brand field is required',
|
||||
});
|
||||
|
||||
export async function registerBrandsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: BrandsRoutesDeps,
|
||||
): Promise<void> {
|
||||
const repository = new PgBrandRepository(deps.pool);
|
||||
const getBySlug = new GetBrandBySlug(repository);
|
||||
const listBrands = new ListBrands(repository);
|
||||
const createBrand = new CreateBrand(repository);
|
||||
const updateBrand = new UpdateBrand(repository);
|
||||
|
||||
app.get('/brands', async (_request, reply) => {
|
||||
const items = await listBrands.execute();
|
||||
return reply.send({ items: items.map(serializeBrand) });
|
||||
});
|
||||
|
||||
app.get('/marca/:slug', async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const brand = await getBySlug.execute(slug);
|
||||
if (!brand) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Brand not found');
|
||||
}
|
||||
return reply.send(serializeBrand(brand));
|
||||
});
|
||||
|
||||
app.post('/brands', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newBrandSchema, request.body);
|
||||
try {
|
||||
const brand = await createBrand.execute(input);
|
||||
return reply.code(201).send(serializeBrand(brand));
|
||||
} catch (error) {
|
||||
throw mapBrandError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/brands/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const patch = parseJson(brandPatchSchema, request.body);
|
||||
try {
|
||||
const brand = await updateBrand.execute(id, patch);
|
||||
if (!brand) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Brand not found');
|
||||
}
|
||||
return reply.send(serializeBrand(brand));
|
||||
} catch (error) {
|
||||
throw mapBrandError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /brands/:id
|
||||
app.delete('/brands/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
try {
|
||||
await repository.delete(id);
|
||||
} catch {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Brand not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
function mapBrandError(error: unknown): Error {
|
||||
if (error instanceof BrandSlugAlreadyExistsError) {
|
||||
return new AppError(409, 'BRAND_SLUG_EXISTS', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown brand error');
|
||||
}
|
||||
|
||||
function serializeBrand(brand: Brand) {
|
||||
return {
|
||||
id: brand.id,
|
||||
name: brand.name,
|
||||
slug: brand.slug,
|
||||
url: `/marca/${brand.slug}`,
|
||||
seoTitle: brand.seoTitle,
|
||||
seoDescription: brand.seoDescription,
|
||||
createdAt: brand.createdAt.toISOString(),
|
||||
updatedAt: brand.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
34
project/src/modules/brands/application/brand-use-cases.ts
Normal file
34
project/src/modules/brands/application/brand-use-cases.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Brand, BrandPatch, NewBrand } from '../domain/brand.js';
|
||||
import type { BrandRepository } from '../domain/ports.js';
|
||||
|
||||
export class GetBrandBySlug {
|
||||
constructor(private readonly brands: BrandRepository) {}
|
||||
|
||||
async execute(slug: string): Promise<Brand | undefined> {
|
||||
return this.brands.findBySlug(slug);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListBrands {
|
||||
constructor(private readonly brands: BrandRepository) {}
|
||||
|
||||
async execute(): Promise<Brand[]> {
|
||||
return this.brands.list();
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateBrand {
|
||||
constructor(private readonly brands: BrandRepository) {}
|
||||
|
||||
async execute(input: NewBrand): Promise<Brand> {
|
||||
return this.brands.create(input);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateBrand {
|
||||
constructor(private readonly brands: BrandRepository) {}
|
||||
|
||||
async execute(id: string, patch: BrandPatch): Promise<Brand | undefined> {
|
||||
return this.brands.update(id, patch);
|
||||
}
|
||||
}
|
||||
22
project/src/modules/brands/domain/brand.ts
Normal file
22
project/src/modules/brands/domain/brand.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Brand domain model. Public storefront URLs use slug; id is internal.
|
||||
*/
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NewBrand {
|
||||
name: string;
|
||||
slug: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
}
|
||||
|
||||
/** Fields a brand update may set. Undefined = leave unchanged. */
|
||||
export type BrandPatch = Partial<NewBrand>;
|
||||
6
project/src/modules/brands/domain/errors.ts
Normal file
6
project/src/modules/brands/domain/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class BrandSlugAlreadyExistsError extends Error {
|
||||
constructor() {
|
||||
super('Brand slug already exists');
|
||||
this.name = 'BrandSlugAlreadyExistsError';
|
||||
}
|
||||
}
|
||||
10
project/src/modules/brands/domain/ports.ts
Normal file
10
project/src/modules/brands/domain/ports.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Brand, BrandPatch, NewBrand } from './brand.js';
|
||||
|
||||
export interface BrandRepository {
|
||||
findById(id: string): Promise<Brand | undefined>;
|
||||
findBySlug(slug: string): Promise<Brand | undefined>;
|
||||
list(): Promise<Brand[]>;
|
||||
create(input: NewBrand): Promise<Brand>;
|
||||
update(id: string, patch: BrandPatch): Promise<Brand | undefined>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
2
project/src/modules/brands/index.ts
Normal file
2
project/src/modules/brands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the brands module. */
|
||||
export { registerBrandsRoutes, type BrandsRoutesDeps } from './api/brands.routes.js';
|
||||
122
project/src/modules/brands/infrastructure/pg-brand-repository.ts
Normal file
122
project/src/modules/brands/infrastructure/pg-brand-repository.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type pg from 'pg';
|
||||
import { BrandSlugAlreadyExistsError } from '../domain/errors.js';
|
||||
import type { Brand, BrandPatch, NewBrand } from '../domain/brand.js';
|
||||
import type { BrandRepository } from '../domain/ports.js';
|
||||
|
||||
interface BrandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
seo_title: string | null;
|
||||
seo_description: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
const UPDATABLE: ReadonlyArray<[keyof BrandPatch, string]> = [
|
||||
['name', 'name'],
|
||||
['slug', 'slug'],
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
];
|
||||
|
||||
export class PgBrandRepository implements BrandRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findById(id: string): Promise<Brand | undefined> {
|
||||
const result = await this.pool.query<BrandRow>('SELECT * FROM brands_brands WHERE id = $1', [
|
||||
id,
|
||||
]);
|
||||
const row = result.rows[0];
|
||||
return row ? toBrand(row) : undefined;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<Brand | undefined> {
|
||||
const result = await this.pool.query<BrandRow>('SELECT * FROM brands_brands WHERE slug = $1', [
|
||||
slug,
|
||||
]);
|
||||
const row = result.rows[0];
|
||||
return row ? toBrand(row) : undefined;
|
||||
}
|
||||
|
||||
async list(): Promise<Brand[]> {
|
||||
const result = await this.pool.query<BrandRow>(
|
||||
'SELECT * FROM brands_brands ORDER BY name ASC, id ASC',
|
||||
);
|
||||
return result.rows.map(toBrand);
|
||||
}
|
||||
|
||||
async create(input: NewBrand): Promise<Brand> {
|
||||
try {
|
||||
const result = await this.pool.query<BrandRow>(
|
||||
`INSERT INTO brands_brands (name, slug, seo_title, seo_description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[input.name, input.slug, input.seoTitle ?? null, input.seoDescription ?? null],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('brands_brands INSERT returned no row');
|
||||
}
|
||||
return toBrand(row);
|
||||
} catch (error) {
|
||||
if (isPgError(error, UNIQUE_VIOLATION)) {
|
||||
throw new BrandSlugAlreadyExistsError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, patch: BrandPatch): Promise<Brand | undefined> {
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, column] of UPDATABLE) {
|
||||
if (key in patch) {
|
||||
values.push(patch[key]);
|
||||
setClauses.push(`${column} = $${values.length}`);
|
||||
}
|
||||
}
|
||||
if (setClauses.length === 0) {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
try {
|
||||
const result = await this.pool.query<BrandRow>(
|
||||
`UPDATE brands_brands SET ${setClauses.join(', ')}, updated_at = now()
|
||||
WHERE id = $${values.length}
|
||||
RETURNING *`,
|
||||
values,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toBrand(row) : undefined;
|
||||
} catch (error) {
|
||||
if (isPgError(error, UNIQUE_VIOLATION)) {
|
||||
throw new BrandSlugAlreadyExistsError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.pool.query('DELETE FROM brands_brands WHERE id = $1', [id]);
|
||||
}
|
||||
}
|
||||
|
||||
function toBrand(row: BrandRow): Brand {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
seoTitle: row.seo_title,
|
||||
seoDescription: row.seo_description,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function isPgError(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
Reference in New Issue
Block a user