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:
Eric Gullickson
2026-01-13 21:28:43 -06:00
parent 5f07123646
commit 81b1c3dd70
10 changed files with 618 additions and 801 deletions

View File

@@ -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();
}
}

View File

@@ -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');
}

View File

@@ -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>;