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,210 +1,160 @@
|
||||
/**
|
||||
* @ai-summary Data access layer for ownership costs
|
||||
* @ai-context Handles database operations for vehicle ownership costs
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
OwnershipCost,
|
||||
CreateOwnershipCostRequest,
|
||||
UpdateOwnershipCostRequest,
|
||||
CostInterval,
|
||||
OwnershipCostType
|
||||
} from '../domain/ownership-costs.types';
|
||||
import pool from '../../../core/config/database';
|
||||
import type { OwnershipCost, OwnershipCostType } from '../domain/ownership-costs.types';
|
||||
|
||||
export class OwnershipCostsRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
async create(data: CreateOwnershipCostRequest & { userId: string }): Promise<OwnershipCost> {
|
||||
const query = `
|
||||
INSERT INTO ownership_costs (
|
||||
user_id, vehicle_id, document_id, cost_type, description,
|
||||
amount, interval, start_date, end_date
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
`;
|
||||
// ========================
|
||||
// Row Mappers
|
||||
// ========================
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.vehicleId,
|
||||
data.documentId ?? null,
|
||||
data.costType,
|
||||
data.description ?? null,
|
||||
data.amount,
|
||||
data.interval,
|
||||
data.startDate,
|
||||
data.endDate ?? null
|
||||
];
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
return this.mapRow(result.rows[0]);
|
||||
private mapRow(row: any): OwnershipCost {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
documentId: row.document_id,
|
||||
costType: row.cost_type,
|
||||
amount: row.amount,
|
||||
description: row.description,
|
||||
periodStart: row.period_start,
|
||||
periodEnd: row.period_end,
|
||||
notes: row.notes,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
async findByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCost[]> {
|
||||
const query = `
|
||||
SELECT * FROM ownership_costs
|
||||
WHERE vehicle_id = $1 AND user_id = $2
|
||||
ORDER BY start_date DESC, created_at DESC
|
||||
`;
|
||||
// ========================
|
||||
// CRUD Operations
|
||||
// ========================
|
||||
|
||||
const result = await this.pool.query(query, [vehicleId, userId]);
|
||||
return result.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<OwnershipCost[]> {
|
||||
const query = `
|
||||
SELECT * FROM ownership_costs
|
||||
WHERE user_id = $1
|
||||
ORDER BY start_date DESC, created_at DESC
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
return result.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<OwnershipCost | null> {
|
||||
const query = 'SELECT * FROM ownership_costs WHERE id = $1';
|
||||
const result = await this.pool.query(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async findByDocumentId(documentId: string): Promise<OwnershipCost[]> {
|
||||
const query = `
|
||||
SELECT * FROM ownership_costs
|
||||
WHERE document_id = $1
|
||||
ORDER BY start_date DESC
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [documentId]);
|
||||
return result.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost | null> {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
// Build dynamic update query
|
||||
if (data.documentId !== undefined) {
|
||||
fields.push(`document_id = $${paramCount++}`);
|
||||
values.push(data.documentId);
|
||||
}
|
||||
if (data.costType !== undefined) {
|
||||
fields.push(`cost_type = $${paramCount++}`);
|
||||
values.push(data.costType);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
fields.push(`description = $${paramCount++}`);
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.amount !== undefined) {
|
||||
fields.push(`amount = $${paramCount++}`);
|
||||
values.push(data.amount);
|
||||
}
|
||||
if (data.interval !== undefined) {
|
||||
fields.push(`interval = $${paramCount++}`);
|
||||
values.push(data.interval);
|
||||
}
|
||||
if (data.startDate !== undefined) {
|
||||
fields.push(`start_date = $${paramCount++}`);
|
||||
values.push(data.startDate);
|
||||
}
|
||||
if (data.endDate !== undefined) {
|
||||
fields.push(`end_date = $${paramCount++}`);
|
||||
values.push(data.endDate);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE ownership_costs
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const query = 'DELETE FROM ownership_costs WHERE id = $1';
|
||||
const result = await this.pool.query(query, [id]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async batchInsert(
|
||||
costs: Array<CreateOwnershipCostRequest & { userId: string }>,
|
||||
client?: Pool
|
||||
): Promise<OwnershipCost[]> {
|
||||
if (costs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryClient = client || this.pool;
|
||||
const placeholders: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
costs.forEach((cost) => {
|
||||
const costParams = [
|
||||
async insert(cost: {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentId?: string | null;
|
||||
costType: OwnershipCostType;
|
||||
amount: number;
|
||||
description?: string | null;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
notes?: string | null;
|
||||
}): Promise<OwnershipCost> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO ownership_costs (
|
||||
id, user_id, vehicle_id, document_id, cost_type, amount, description, period_start, period_end, notes
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
cost.id,
|
||||
cost.userId,
|
||||
cost.vehicleId,
|
||||
cost.documentId ?? null,
|
||||
cost.costType,
|
||||
cost.description ?? null,
|
||||
cost.amount,
|
||||
cost.interval,
|
||||
cost.startDate,
|
||||
cost.endDate ?? null
|
||||
];
|
||||
|
||||
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
||||
placeholders.push(placeholder);
|
||||
values.push(...costParams);
|
||||
});
|
||||
|
||||
const query = `
|
||||
INSERT INTO ownership_costs (
|
||||
user_id, vehicle_id, document_id, cost_type, description,
|
||||
amount, interval, start_date, end_date
|
||||
)
|
||||
VALUES ${placeholders.join(', ')}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryClient.query(query, values);
|
||||
return result.rows.map((row: Record<string, unknown>) => this.mapRow(row));
|
||||
cost.description ?? null,
|
||||
cost.periodStart ?? null,
|
||||
cost.periodEnd ?? null,
|
||||
cost.notes ?? null,
|
||||
]
|
||||
);
|
||||
return this.mapRow(res.rows[0]);
|
||||
}
|
||||
|
||||
private mapRow(row: Record<string, unknown>): OwnershipCost {
|
||||
return {
|
||||
id: row.id as string,
|
||||
userId: row.user_id as string,
|
||||
vehicleId: row.vehicle_id as string,
|
||||
documentId: row.document_id as string | undefined,
|
||||
costType: row.cost_type as OwnershipCostType,
|
||||
description: row.description as string | undefined,
|
||||
amount: Number(row.amount),
|
||||
interval: row.interval as CostInterval,
|
||||
startDate: row.start_date as Date,
|
||||
endDate: row.end_date as Date | undefined,
|
||||
createdAt: row.created_at as Date,
|
||||
updatedAt: row.updated_at as Date,
|
||||
};
|
||||
async findById(id: string, userId: string): Promise<OwnershipCost | null> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM ownership_costs WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
filters?: { vehicleId?: string; costType?: OwnershipCostType; documentId?: string }
|
||||
): Promise<OwnershipCost[]> {
|
||||
const conds: string[] = ['user_id = $1'];
|
||||
const params: any[] = [userId];
|
||||
let i = 2;
|
||||
|
||||
if (filters?.vehicleId) {
|
||||
conds.push(`vehicle_id = $${i++}`);
|
||||
params.push(filters.vehicleId);
|
||||
}
|
||||
if (filters?.costType) {
|
||||
conds.push(`cost_type = $${i++}`);
|
||||
params.push(filters.costType);
|
||||
}
|
||||
if (filters?.documentId) {
|
||||
conds.push(`document_id = $${i++}`);
|
||||
params.push(filters.documentId);
|
||||
}
|
||||
|
||||
const sql = `SELECT * FROM ownership_costs WHERE ${conds.join(' AND ')} ORDER BY period_start DESC, created_at DESC`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCost[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM ownership_costs WHERE vehicle_id = $1 AND user_id = $2 ORDER BY period_start DESC, created_at DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<OwnershipCost, 'documentId' | 'costType' | 'amount' | 'description' | 'periodStart' | 'periodEnd' | 'notes'>>
|
||||
): Promise<OwnershipCost | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (patch.documentId !== undefined) {
|
||||
fields.push(`document_id = $${i++}`);
|
||||
params.push(patch.documentId);
|
||||
}
|
||||
if (patch.costType !== undefined) {
|
||||
fields.push(`cost_type = $${i++}`);
|
||||
params.push(patch.costType);
|
||||
}
|
||||
if (patch.amount !== undefined) {
|
||||
fields.push(`amount = $${i++}`);
|
||||
params.push(patch.amount);
|
||||
}
|
||||
if (patch.description !== undefined) {
|
||||
fields.push(`description = $${i++}`);
|
||||
params.push(patch.description);
|
||||
}
|
||||
if (patch.periodStart !== undefined) {
|
||||
fields.push(`period_start = $${i++}`);
|
||||
params.push(patch.periodStart);
|
||||
}
|
||||
if (patch.periodEnd !== undefined) {
|
||||
fields.push(`period_end = $${i++}`);
|
||||
params.push(patch.periodEnd);
|
||||
}
|
||||
if (patch.notes !== undefined) {
|
||||
fields.push(`notes = $${i++}`);
|
||||
params.push(patch.notes);
|
||||
}
|
||||
|
||||
if (!fields.length) return this.findById(id, userId);
|
||||
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE ownership_costs SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.db.query(
|
||||
`DELETE FROM ownership_costs WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user