feat: create ownership_costs backend feature capsule (refs #29)
Milestone 1: Complete backend feature with: - Migration with CHECK (amount > 0) constraint - Repository with mapRow() for snake_case -> camelCase - Service with CRUD and vehicle authorization - Controller with HTTP handlers - Routes registered at /api/ownership-costs - Validation with Zod schemas - README with endpoint documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,225 +1,145 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for ownership costs API
|
||||
* @ai-context HTTP request/response handling with Fastify reply methods
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { OwnershipCostsService } from '../domain/ownership-costs.service';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import type { CreateBody, UpdateBody, IdParams, ListQuery } from './ownership-costs.validation';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
OwnershipCostParams,
|
||||
VehicleParams,
|
||||
CreateOwnershipCostBody,
|
||||
UpdateOwnershipCostBody
|
||||
} from '../domain/ownership-costs.types';
|
||||
|
||||
export class OwnershipCostsController {
|
||||
private service: OwnershipCostsService;
|
||||
private readonly service = new OwnershipCostsService();
|
||||
|
||||
constructor() {
|
||||
this.service = new OwnershipCostsService(pool);
|
||||
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
|
||||
logger.info('Ownership costs list requested', {
|
||||
operation: 'ownership-costs.list',
|
||||
userId,
|
||||
filters: {
|
||||
vehicleId: request.query.vehicleId,
|
||||
costType: request.query.costType,
|
||||
documentId: request.query.documentId,
|
||||
},
|
||||
});
|
||||
|
||||
const costs = await this.service.getCosts(userId, {
|
||||
vehicleId: request.query.vehicleId,
|
||||
costType: request.query.costType,
|
||||
documentId: request.query.documentId,
|
||||
});
|
||||
|
||||
logger.info('Ownership costs list retrieved', {
|
||||
operation: 'ownership-costs.list.success',
|
||||
userId,
|
||||
costCount: costs.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(costs);
|
||||
}
|
||||
|
||||
async create(request: FastifyRequest<{ Body: CreateOwnershipCostBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userId = (request as any).user.sub;
|
||||
const cost = await this.service.create(request.body, userId);
|
||||
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const costId = request.params.id;
|
||||
|
||||
return reply.code(201).send(cost);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger.error('Error creating ownership cost', { error: err, userId: (request as any).user?.sub });
|
||||
logger.info('Ownership cost get requested', {
|
||||
operation: 'ownership-costs.get',
|
||||
userId,
|
||||
costId,
|
||||
});
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to create ownership cost'
|
||||
const cost = await this.service.getCost(userId, costId);
|
||||
if (!cost) {
|
||||
logger.warn('Ownership cost not found', {
|
||||
operation: 'ownership-costs.get.not_found',
|
||||
userId,
|
||||
costId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Ownership cost retrieved', {
|
||||
operation: 'ownership-costs.get.success',
|
||||
userId,
|
||||
costId,
|
||||
vehicleId: cost.vehicleId,
|
||||
costType: cost.costType,
|
||||
});
|
||||
|
||||
return reply.code(200).send(cost);
|
||||
}
|
||||
|
||||
async getByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userId = (request as any).user.sub;
|
||||
const { vehicleId } = request.params;
|
||||
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
|
||||
const costs = await this.service.getByVehicleId(vehicleId, userId);
|
||||
logger.info('Ownership cost create requested', {
|
||||
operation: 'ownership-costs.create',
|
||||
userId,
|
||||
vehicleId: request.body.vehicleId,
|
||||
costType: request.body.costType,
|
||||
amount: request.body.amount,
|
||||
});
|
||||
|
||||
return reply.code(200).send(costs);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger.error('Error listing ownership costs', { error: err, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
||||
const created = await this.service.createCost(userId, request.body);
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
logger.info('Ownership cost created', {
|
||||
operation: 'ownership-costs.create.success',
|
||||
userId,
|
||||
costId: created.id,
|
||||
vehicleId: created.vehicleId,
|
||||
costType: created.costType,
|
||||
amount: created.amount,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get ownership costs'
|
||||
});
|
||||
}
|
||||
return reply.code(201).send(created);
|
||||
}
|
||||
|
||||
async getById(request: FastifyRequest<{ Params: OwnershipCostParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const costId = request.params.id;
|
||||
|
||||
const cost = await this.service.getById(id, userId);
|
||||
logger.info('Ownership cost update requested', {
|
||||
operation: 'ownership-costs.update',
|
||||
userId,
|
||||
costId,
|
||||
updateFields: Object.keys(request.body),
|
||||
});
|
||||
|
||||
return reply.code(200).send(cost);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger.error('Error getting ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get ownership cost'
|
||||
const updated = await this.service.updateCost(userId, costId, request.body);
|
||||
if (!updated) {
|
||||
logger.warn('Ownership cost not found for update', {
|
||||
operation: 'ownership-costs.update.not_found',
|
||||
userId,
|
||||
costId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Ownership cost updated', {
|
||||
operation: 'ownership-costs.update.success',
|
||||
userId,
|
||||
costId,
|
||||
vehicleId: updated.vehicleId,
|
||||
costType: updated.costType,
|
||||
});
|
||||
|
||||
return reply.code(200).send(updated);
|
||||
}
|
||||
|
||||
async update(request: FastifyRequest<{ Params: OwnershipCostParams; Body: UpdateOwnershipCostBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const costId = request.params.id;
|
||||
|
||||
const updatedCost = await this.service.update(id, request.body, userId);
|
||||
logger.info('Ownership cost delete requested', {
|
||||
operation: 'ownership-costs.delete',
|
||||
userId,
|
||||
costId,
|
||||
});
|
||||
|
||||
return reply.code(200).send(updatedCost);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger.error('Error updating ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
|
||||
await this.service.deleteCost(userId, costId);
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
logger.info('Ownership cost deleted', {
|
||||
operation: 'ownership-costs.delete.success',
|
||||
userId,
|
||||
costId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update ownership cost'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async delete(request: FastifyRequest<{ Params: OwnershipCostParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
|
||||
await this.service.delete(id, userId);
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger.error('Error deleting ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to delete ownership cost'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getVehicleStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userId = (request as any).user.sub;
|
||||
const { vehicleId } = request.params;
|
||||
|
||||
const stats = await this.service.getVehicleCostStats(vehicleId, userId);
|
||||
|
||||
return reply.code(200).send(stats);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger.error('Error getting ownership cost stats', { error: err, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get ownership cost stats'
|
||||
});
|
||||
}
|
||||
return reply.code(204).send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,38 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for ownership costs API
|
||||
* @ai-context Route definitions with Fastify plugin pattern and authentication
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
|
||||
import { OwnershipCostsController } from './ownership-costs.controller';
|
||||
|
||||
export const ownershipCostsRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const controller = new OwnershipCostsController();
|
||||
const ctrl = new OwnershipCostsController();
|
||||
const requireAuth = fastify.authenticate.bind(fastify);
|
||||
|
||||
// POST /api/ownership-costs - Create new ownership cost
|
||||
fastify.post('/ownership-costs', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.create.bind(controller)
|
||||
fastify.get('/ownership-costs', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.list.bind(ctrl)
|
||||
});
|
||||
|
||||
// GET /api/ownership-costs/:id - Get specific ownership cost
|
||||
fastify.get('/ownership-costs/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getById.bind(controller)
|
||||
fastify.get<{ Params: any }>('/ownership-costs/:id', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.get.bind(ctrl)
|
||||
});
|
||||
|
||||
// PUT /api/ownership-costs/:id - Update ownership cost
|
||||
fastify.put('/ownership-costs/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.update.bind(controller)
|
||||
fastify.post<{ Body: any }>('/ownership-costs', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.create.bind(ctrl)
|
||||
});
|
||||
|
||||
// DELETE /api/ownership-costs/:id - Delete ownership cost
|
||||
fastify.delete('/ownership-costs/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.delete.bind(controller)
|
||||
fastify.put<{ Params: any; Body: any }>('/ownership-costs/:id', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.update.bind(ctrl)
|
||||
});
|
||||
|
||||
// GET /api/ownership-costs/vehicle/:vehicleId - Get costs for a vehicle
|
||||
fastify.get('/ownership-costs/vehicle/:vehicleId', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getByVehicle.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/ownership-costs/vehicle/:vehicleId/stats - Get aggregated cost stats
|
||||
fastify.get('/ownership-costs/vehicle/:vehicleId/stats', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getVehicleStats.bind(controller)
|
||||
fastify.delete<{ Params: any }>('/ownership-costs/:id', {
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.remove.bind(ctrl)
|
||||
});
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
export function registerOwnershipCostsRoutes() {
|
||||
throw new Error('registerOwnershipCostsRoutes is deprecated - use ownershipCostsRoutes Fastify plugin instead');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
import { CreateOwnershipCostSchema, UpdateOwnershipCostSchema, OwnershipCostTypeSchema } from '../domain/ownership-costs.types';
|
||||
|
||||
export const ListQuerySchema = z.object({
|
||||
vehicleId: z.string().uuid().optional(),
|
||||
costType: OwnershipCostTypeSchema.optional(),
|
||||
documentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export const CreateBodySchema = CreateOwnershipCostSchema;
|
||||
export const UpdateBodySchema = UpdateOwnershipCostSchema;
|
||||
|
||||
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
||||
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
||||
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
||||
Reference in New Issue
Block a user