feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,94 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { parseJson } from '../../../shared/http-input.js';
import { AppError } from '../../../shared/errors.js';
import { PromotionServiceImpl } from '../application/promotion-service.js';
import type { Promotion } from '../domain/promotion.js';
import { PgPromotionRepository } from '../infrastructure/pg-promotion-repository.js';
export interface PromotionsRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const promotionBodySchema = z.object({
code: z.string().min(1).max(64),
type: z.enum(['percent', 'fixed_amount']),
value: z.number().int().positive(),
startsAt: z.coerce.date(),
endsAt: z.coerce.date(),
usageLimit: z.number().int().positive().optional().nullable(),
active: z.boolean().optional(),
});
const promotionPatchSchema = promotionBodySchema.partial().omit({ code: true });
export async function registerPromotionsRoutes(
app: FastifyInstance,
deps: PromotionsRoutesDeps,
): Promise<void> {
const service = new PromotionServiceImpl(new PgPromotionRepository(deps.pool));
// List all promotions (admin)
app.get('/promotions', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const items = await service.list();
return reply.send({ items: items.map(serializePromotion) });
});
// Create promotion (admin)
app.post('/promotions', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(promotionBodySchema, request.body);
const promotion = await service.create(input);
return reply.code(201).send(serializePromotion(promotion));
});
// Update promotion (admin)
app.patch('/promotions/:code', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { code } = parseJson(z.object({ code: z.string() }), request.params);
const patch = parseJson(promotionPatchSchema, request.body);
try {
const promotion = await service.update(code, patch);
return reply.send(serializePromotion(promotion));
} catch (error) {
throw error instanceof Error && error.message === 'Promotion not found'
? new AppError(404, 'NOT_FOUND', 'Promotion not found')
: (error instanceof Error ? error : new Error('Unknown error'));
}
});
// Delete promotion (admin)
app.delete('/promotions/:code', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { code } = parseJson(z.object({ code: z.string() }), request.params);
try {
await service.delete(code);
} catch {
throw new AppError(404, 'NOT_FOUND', 'Promotion not found');
}
return reply.code(204).send();
});
}
function serializePromotion(promotion: Promotion) {
return {
code: promotion.code,
type: promotion.type,
value: promotion.value,
startsAt: promotion.startsAt.toISOString(),
endsAt: promotion.endsAt.toISOString(),
usageLimit: promotion.usageLimit,
usageCount: promotion.usageCount,
active: promotion.active,
createdAt: promotion.createdAt.toISOString(),
updatedAt: promotion.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,59 @@
import type { NewPromotion, Promotion, PromotionDiscount, PromotionType } from '../domain/promotion.js';
import { PromotionInvalidError } from '../domain/errors.js';
import type { PromotionRepository, PromotionService } from '../domain/ports.js';
function isActiveAt(promotion: Promotion, now: Date): boolean {
return promotion.active && now >= promotion.startsAt && now <= promotion.endsAt;
}
export class PromotionServiceImpl implements PromotionService {
constructor(private readonly promotions: PromotionRepository) {}
async create(input: NewPromotion): Promise<Promotion> {
return this.promotions.create(input);
}
async list(): Promise<Promotion[]> {
return this.promotions.findAll();
}
async update(code: string, patch: Partial<NewPromotion>): Promise<Promotion> {
return this.promotions.update(code, patch);
}
async delete(code: string): Promise<void> {
return this.promotions.delete(code);
}
async calculateDiscount(
code: string,
cartTotalCents: number,
now = new Date(),
): Promise<PromotionDiscount> {
const promotion = await this.promotions.findByCode(code);
if (!promotion || !isActiveAt(promotion, now)) {
throw new Error('Promotion not found or inactive');
}
const discountCents =
promotion.type === 'percent'
? Math.round((cartTotalCents * promotion.value) / 10000)
: Math.min(promotion.value, cartTotalCents);
return { code: promotion.code, type: promotion.type, discountCents };
}
async validateCode(code: string, now = new Date()): Promise<Promotion> {
const promotion = await this.promotions.findByCode(code);
if (!promotion) {
throw new PromotionInvalidError('Promotion not found');
}
if (!isActiveAt(promotion, now)) {
throw new PromotionInvalidError('Promotion not active');
}
if (promotion.usageLimit != null && promotion.usageCount >= promotion.usageLimit) {
throw new PromotionInvalidError('Promotion usage limit reached');
}
return promotion;
}
}

View File

@@ -0,0 +1,13 @@
export class PromotionInvalidError extends Error {
constructor(message: string) {
super(message);
this.name = 'PromotionInvalidError';
}
}
export class PromotionNotFoundError extends PromotionInvalidError {
constructor() {
super('Promotion code not found');
this.name = 'PromotionNotFoundError';
}
}

View File

@@ -0,0 +1,18 @@
import type { NewPromotion, Promotion, PromotionDiscount } from './promotion.js';
export interface PromotionService {
create(input: NewPromotion): Promise<Promotion>;
calculateDiscount(code: string, cartTotalCents: number, now?: Date): Promise<PromotionDiscount>;
validateCode(code: string, now?: Date): Promise<Promotion>;
list(): Promise<Promotion[]>;
update(code: string, patch: Partial<NewPromotion>): Promise<Promotion>;
delete(code: string): Promise<void>;
}
export interface PromotionRepository {
findByCode(code: string): Promise<Promotion | undefined>;
create(input: NewPromotion): Promise<Promotion>;
findAll(): Promise<Promotion[]>;
update(code: string, patch: Partial<NewPromotion>): Promise<Promotion>;
delete(code: string): Promise<void>;
}

View File

@@ -0,0 +1,30 @@
export type PromotionType = 'percent' | 'fixed_amount';
export interface Promotion {
code: string;
type: PromotionType;
value: number;
startsAt: Date;
endsAt: Date;
usageLimit: number | null;
usageCount: number;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface NewPromotion {
code: string;
type: PromotionType;
value: number;
startsAt: Date;
endsAt: Date;
usageLimit?: number | null;
active?: boolean;
}
export interface PromotionDiscount {
code: string;
type: PromotionType;
discountCents: number;
}

View File

@@ -0,0 +1,19 @@
/** Public API of the promotions module. */
import type pg from 'pg';
import { PromotionServiceImpl } from './application/promotion-service.js';
import type { PromotionService, PromotionRepository } from './domain/ports.js';
import { PgPromotionRepository } from './infrastructure/pg-promotion-repository.js';
export { registerPromotionsRoutes, type PromotionsRoutesDeps } from './api/promotions.routes.js';
export type { PromotionService as PromotionServicePort } from './domain/ports.js';
export type { PromotionRepository } from './domain/ports.js';
export type {
NewPromotion,
Promotion,
PromotionDiscount,
PromotionType,
} from './domain/promotion.js';
export function createPromotionService(pool: pg.Pool): PromotionService {
return new PromotionServiceImpl(new PgPromotionRepository(pool));
}

View File

@@ -0,0 +1,125 @@
import type pg from 'pg';
import type { NewPromotion, Promotion, PromotionType } from '../domain/promotion.js';
import type { PromotionRepository } from '../domain/ports.js';
interface PromotionRow {
code: string;
type: PromotionType;
value: number;
starts_at: Date;
ends_at: Date;
usage_limit: number | null;
usage_count: number;
active: boolean;
created_at: Date;
updated_at: Date;
}
export class PgPromotionRepository implements PromotionRepository {
constructor(private readonly pool: pg.Pool) {}
async findByCode(code: string): Promise<Promotion | undefined> {
const result = await this.pool.query<PromotionRow>(
'SELECT * FROM promotions_promotions WHERE code = $1',
[code],
);
const row = result.rows[0];
return row ? toPromotion(row) : undefined;
}
async create(input: NewPromotion): Promise<Promotion> {
const result = await this.pool.query<PromotionRow>(
`INSERT INTO promotions_promotions (code, type, value, starts_at, ends_at, usage_limit, active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (code) DO UPDATE
SET type = EXCLUDED.type, value = EXCLUDED.value, starts_at = EXCLUDED.starts_at,
ends_at = EXCLUDED.ends_at, usage_limit = EXCLUDED.usage_limit,
active = EXCLUDED.active, updated_at = now()
RETURNING *`,
[
input.code,
input.type,
input.value,
input.startsAt,
input.endsAt,
input.usageLimit ?? null,
input.active ?? true,
],
);
const row = result.rows[0];
if (!row) throw new Error('promotions_promotions upsert returned no row');
return toPromotion(row);
}
async findAll(): Promise<Promotion[]> {
const result = await this.pool.query<PromotionRow>(
'SELECT * FROM promotions_promotions ORDER BY created_at DESC',
);
return result.rows.map(toPromotion);
}
async update(code: string, patch: Partial<NewPromotion>): Promise<Promotion> {
const fields: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (patch.code !== undefined) {
fields.push(`code = $${idx++}`);
values.push(patch.code);
}
if (patch.type !== undefined) {
fields.push(`type = $${idx++}`);
values.push(patch.type);
}
if (patch.value !== undefined) {
fields.push(`value = $${idx++}`);
values.push(patch.value);
}
if (patch.startsAt !== undefined) {
fields.push(`starts_at = $${idx++}`);
values.push(patch.startsAt);
}
if (patch.endsAt !== undefined) {
fields.push(`ends_at = $${idx++}`);
values.push(patch.endsAt);
}
if (patch.usageLimit !== undefined) {
fields.push(`usage_limit = $${idx++}`);
values.push(patch.usageLimit);
}
if (patch.active !== undefined) {
fields.push(`active = $${idx++}`);
values.push(patch.active);
}
fields.push('updated_at = now()');
values.push(code);
const result = await this.pool.query<PromotionRow>(
`UPDATE promotions_promotions SET ${fields.join(', ')} WHERE code = $${idx} RETURNING *`,
values,
);
const row = result.rows[0];
if (!row) throw new Error('Promotion not found');
return toPromotion(row);
}
async delete(code: string): Promise<void> {
await this.pool.query('DELETE FROM promotions_promotions WHERE code = $1', [code]);
}
}
function toPromotion(row: PromotionRow): Promotion {
return {
code: row.code,
type: row.type,
value: row.value,
startsAt: row.starts_at,
endsAt: row.ends_at,
usageLimit: row.usage_limit,
usageCount: row.usage_count,
active: row.active,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { PromotionServiceImpl } from '../application/promotion-service.js';
import { PromotionInvalidError } from '../domain/errors.js';
import type { PromotionRepository } from '../domain/ports.js';
import type { NewPromotion, Promotion } from '../domain/promotion.js';
const PROMO: Promotion = {
code: 'SAVE10',
type: 'percent',
value: 1000,
startsAt: new Date('2026-01-01T00:00:00Z'),
endsAt: new Date('2026-12-31T00:00:00Z'),
usageLimit: null,
usageCount: 0,
active: true,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
function repo(promotion: Promotion = PROMO): PromotionRepository {
return {
findByCode: async () => promotion,
create: async (input: NewPromotion) => ({
...promotion,
...input,
usageLimit: input.usageLimit ?? null,
usageCount: 0,
active: input.active ?? true,
}),
findAll: async () => [promotion],
update: async (_code: string, _patch: Partial<NewPromotion>) => promotion,
delete: async (_code: string) => {},
};
}
describe('PromotionService', () => {
it('calculates percent and fixed discounts capped by cart total', async () => {
await expect(
new PromotionServiceImpl(repo()).calculateDiscount(
'save10',
2000,
new Date('2026-06-01T00:00:00Z'),
),
).resolves.toMatchObject({ code: 'SAVE10', discountCents: 200 });
await expect(
new PromotionServiceImpl(repo({ ...PROMO, type: 'fixed_amount', value: 5000 })).calculateDiscount(
'x',
1200,
new Date('2026-06-01T00:00:00Z'),
),
).resolves.toMatchObject({ discountCents: 1200 });
});
it('rejects expired or exhausted promotions', async () => {
await expect(
new PromotionServiceImpl(repo()).validateCode('save10', new Date('2027-01-01T00:00:00Z')),
).rejects.toBeInstanceOf(PromotionInvalidError);
await expect(
new PromotionServiceImpl(repo({ ...PROMO, usageLimit: 1, usageCount: 1 })).validateCode(
'save10',
new Date('2026-06-01T00:00:00Z'),
),
).rejects.toBeInstanceOf(PromotionInvalidError);
});
});