Admin User v1

This commit is contained in:
Eric Gullickson
2025-11-05 19:04:06 -06:00
parent e4e7e32a4f
commit 8174e0d5f9
48 changed files with 11289 additions and 1112 deletions

View File

@@ -0,0 +1,399 @@
/**
* @ai-summary Fastify route handlers for admin management API
* @ai-context HTTP request/response handling with admin authorization and audit logging
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { AdminService } from '../domain/admin.service';
import { AdminRepository } from '../data/admin.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import {
CreateAdminInput,
AdminAuth0SubInput,
AuditLogsQueryInput
} from './admin.validation';
import {
createAdminSchema,
adminAuth0SubSchema,
auditLogsQuerySchema
} from './admin.validation';
export class AdminController {
private adminService: AdminService;
constructor() {
const repository = new AdminRepository(pool);
this.adminService = new AdminService(repository);
}
/**
* GET /api/admin/verify - Verify admin access (for frontend auth checks)
*/
async verifyAccess(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = request.userContext?.userId;
const userEmail = this.resolveUserEmail(request);
if (userEmail && request.userContext) {
request.userContext.email = userEmail.toLowerCase();
}
if (!userId && !userEmail) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
let adminRecord = userId
? await this.adminService.getAdminByAuth0Sub(userId)
: null;
// Fallback: attempt to resolve admin by email for legacy records
if (!adminRecord && userEmail) {
const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase());
if (emailMatch && !emailMatch.revokedAt) {
// If the stored auth0Sub differs, link it to the authenticated user
if (userId && emailMatch.auth0Sub !== userId) {
adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId);
} else {
adminRecord = emailMatch;
}
}
}
if (adminRecord && !adminRecord.revokedAt) {
if (request.userContext) {
request.userContext.isAdmin = true;
request.userContext.adminRecord = adminRecord;
}
// User is an active admin
return reply.code(200).send({
isAdmin: true,
adminRecord: {
auth0Sub: adminRecord.auth0Sub,
email: adminRecord.email,
role: adminRecord.role
}
});
}
if (request.userContext) {
request.userContext.isAdmin = false;
request.userContext.adminRecord = undefined;
}
// User is not an admin
return reply.code(200).send({
isAdmin: false,
adminRecord: null
});
} catch (error) {
logger.error('Error verifying admin access', {
error: error instanceof Error ? error.message : 'Unknown error',
userId: request.userContext?.userId?.substring(0, 8) + '...'
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Admin verification failed'
});
}
}
/**
* GET /api/admin/admins - List all admin users
*/
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
const admins = await this.adminService.getAllAdmins();
// Log VIEW action
await this.adminService.getAdminByAuth0Sub(actorId);
// Note: Not logging VIEW as it would create excessive audit entries
// VIEW logging can be enabled if needed for compliance
return reply.code(200).send({
total: admins.length,
admins
});
} catch (error: any) {
logger.error('Error listing admins', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to list admins'
});
}
}
/**
* POST /api/admin/admins - Create new admin user
*/
async createAdmin(
request: FastifyRequest<{ Body: CreateAdminInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate request body
const validation = createAdminSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: validation.error.errors
});
}
const { email, role } = validation.data;
// Generate auth0Sub for the new admin
// In production, this should be the actual Auth0 user ID
// For now, we'll use email-based identifier
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
const admin = await this.adminService.createAdmin(
email,
role,
auth0Sub,
actorId
);
return reply.code(201).send(admin);
} catch (error: any) {
logger.error('Error creating admin', {
error: error.message,
actorId: request.userContext?.userId
});
if (error.message.includes('already exists')) {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to create admin'
});
}
}
/**
* PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
*/
async revokeAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate params
const validation = adminAuth0SubSchema.safeParse(request.params);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid auth0Sub parameter',
details: validation.error.errors
});
}
const { auth0Sub } = validation.data;
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
if (!targetAdmin) {
return reply.code(404).send({
error: 'Not Found',
message: 'Admin user not found'
});
}
// Revoke the admin (service handles last admin check)
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
return reply.code(200).send(admin);
} catch (error: any) {
logger.error('Error revoking admin', {
error: error.message,
actorId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub
});
if (error.message.includes('Cannot revoke the last active admin')) {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to revoke admin'
});
}
}
/**
* PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
*/
async reinstateAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate params
const validation = adminAuth0SubSchema.safeParse(request.params);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid auth0Sub parameter',
details: validation.error.errors
});
}
const { auth0Sub } = validation.data;
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
if (!targetAdmin) {
return reply.code(404).send({
error: 'Not Found',
message: 'Admin user not found'
});
}
// Reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
return reply.code(200).send(admin);
} catch (error: any) {
logger.error('Error reinstating admin', {
error: error.message,
actorId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub
});
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to reinstate admin'
});
}
}
/**
* GET /api/admin/audit-logs - Fetch audit trail
*/
async getAuditLogs(
request: FastifyRequest<{ Querystring: AuditLogsQueryInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate query params
const validation = auditLogsQuerySchema.safeParse(request.query);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid query parameters',
details: validation.error.errors
});
}
const { limit, offset } = validation.data;
const result = await this.adminService.getAuditLogs(limit, offset);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error fetching audit logs', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to fetch audit logs'
});
}
}
private resolveUserEmail(request: FastifyRequest): string | undefined {
const candidates: Array<string | undefined> = [
request.userContext?.email,
(request as any).user?.email,
(request as any).user?.['https://motovaultpro.com/email'],
(request as any).user?.['https://motovaultpro.com/user_email'],
(request as any).user?.preferred_username,
];
for (const value of candidates) {
if (typeof value === 'string' && value.includes('@')) {
return value.trim();
}
}
return undefined;
}
}

View File

@@ -0,0 +1,222 @@
/**
* @ai-summary Admin feature routes
* @ai-context Registers admin API endpoints with proper guards
*/
import { FastifyPluginAsync } from 'fastify';
import { AdminController } from './admin.controller';
import {
CreateAdminInput,
AdminAuth0SubInput,
AuditLogsQueryInput
} from './admin.validation';
import { AdminRepository } from '../data/admin.repository';
import { StationOversightService } from '../domain/station-oversight.service';
import { StationsController } from './stations.controller';
import { CatalogController } from './catalog.controller';
import { VehicleCatalogService } from '../domain/vehicle-catalog.service';
import { PlatformCacheService } from '../../platform/domain/platform-cache.service';
import { cacheService } from '../../../core/config/redis';
import { pool } from '../../../core/config/database';
export const adminRoutes: FastifyPluginAsync = async (fastify) => {
const adminController = new AdminController();
// Initialize station oversight dependencies
const adminRepository = new AdminRepository(pool);
const stationOversightService = new StationOversightService(pool, adminRepository);
const stationsController = new StationsController(stationOversightService);
// Initialize catalog dependencies
const platformCacheService = new PlatformCacheService(cacheService);
const catalogService = new VehicleCatalogService(pool, platformCacheService);
const catalogController = new CatalogController(catalogService);
// Admin access verification (used by frontend auth checks)
fastify.get('/admin/verify', {
preHandler: [fastify.authenticate] // Requires JWT, does NOT require admin role
}, adminController.verifyAccess.bind(adminController));
// Phase 2: Admin management endpoints
// GET /api/admin/admins - List all admin users
fastify.get('/admin/admins', {
preHandler: [fastify.requireAdmin],
handler: adminController.listAdmins.bind(adminController)
});
// POST /api/admin/admins - Create new admin
fastify.post<{ Body: CreateAdminInput }>('/admin/admins', {
preHandler: [fastify.requireAdmin],
handler: adminController.createAdmin.bind(adminController)
});
// PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/revoke', {
preHandler: [fastify.requireAdmin],
handler: adminController.revokeAdmin.bind(adminController)
});
// PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/reinstate', {
preHandler: [fastify.requireAdmin],
handler: adminController.reinstateAdmin.bind(adminController)
});
// GET /api/admin/audit-logs - Fetch audit trail
fastify.get<{ Querystring: AuditLogsQueryInput }>('/admin/audit-logs', {
preHandler: [fastify.requireAdmin],
handler: adminController.getAuditLogs.bind(adminController)
});
// Phase 3: Catalog CRUD endpoints
// Makes endpoints
fastify.get('/admin/catalog/makes', {
preHandler: [fastify.requireAdmin],
handler: catalogController.getMakes.bind(catalogController)
});
fastify.post('/admin/catalog/makes', {
preHandler: [fastify.requireAdmin],
handler: catalogController.createMake.bind(catalogController)
});
fastify.put('/admin/catalog/makes/:makeId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.updateMake.bind(catalogController)
});
fastify.delete('/admin/catalog/makes/:makeId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.deleteMake.bind(catalogController)
});
// Models endpoints
fastify.get('/admin/catalog/makes/:makeId/models', {
preHandler: [fastify.requireAdmin],
handler: catalogController.getModels.bind(catalogController)
});
fastify.post('/admin/catalog/models', {
preHandler: [fastify.requireAdmin],
handler: catalogController.createModel.bind(catalogController)
});
fastify.put('/admin/catalog/models/:modelId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.updateModel.bind(catalogController)
});
fastify.delete('/admin/catalog/models/:modelId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.deleteModel.bind(catalogController)
});
// Years endpoints
fastify.get('/admin/catalog/models/:modelId/years', {
preHandler: [fastify.requireAdmin],
handler: catalogController.getYears.bind(catalogController)
});
fastify.post('/admin/catalog/years', {
preHandler: [fastify.requireAdmin],
handler: catalogController.createYear.bind(catalogController)
});
fastify.put('/admin/catalog/years/:yearId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.updateYear.bind(catalogController)
});
fastify.delete('/admin/catalog/years/:yearId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.deleteYear.bind(catalogController)
});
// Trims endpoints
fastify.get('/admin/catalog/years/:yearId/trims', {
preHandler: [fastify.requireAdmin],
handler: catalogController.getTrims.bind(catalogController)
});
fastify.post('/admin/catalog/trims', {
preHandler: [fastify.requireAdmin],
handler: catalogController.createTrim.bind(catalogController)
});
fastify.put('/admin/catalog/trims/:trimId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.updateTrim.bind(catalogController)
});
fastify.delete('/admin/catalog/trims/:trimId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.deleteTrim.bind(catalogController)
});
// Engines endpoints
fastify.get('/admin/catalog/trims/:trimId/engines', {
preHandler: [fastify.requireAdmin],
handler: catalogController.getEngines.bind(catalogController)
});
fastify.post('/admin/catalog/engines', {
preHandler: [fastify.requireAdmin],
handler: catalogController.createEngine.bind(catalogController)
});
fastify.put('/admin/catalog/engines/:engineId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.updateEngine.bind(catalogController)
});
fastify.delete('/admin/catalog/engines/:engineId', {
preHandler: [fastify.requireAdmin],
handler: catalogController.deleteEngine.bind(catalogController)
});
// Change logs endpoint
fastify.get('/admin/catalog/change-logs', {
preHandler: [fastify.requireAdmin],
handler: catalogController.getChangeLogs.bind(catalogController)
});
// Phase 4: Station oversight endpoints
// GET /api/admin/stations - List all stations globally
fastify.get('/admin/stations', {
preHandler: [fastify.requireAdmin],
handler: stationsController.listAllStations.bind(stationsController)
});
// POST /api/admin/stations - Create new station
fastify.post('/admin/stations', {
preHandler: [fastify.requireAdmin],
handler: stationsController.createStation.bind(stationsController)
});
// PUT /api/admin/stations/:stationId - Update station
fastify.put('/admin/stations/:stationId', {
preHandler: [fastify.requireAdmin],
handler: stationsController.updateStation.bind(stationsController)
});
// DELETE /api/admin/stations/:stationId - Delete station (soft delete by default, ?force=true for hard delete)
fastify.delete('/admin/stations/:stationId', {
preHandler: [fastify.requireAdmin],
handler: stationsController.deleteStation.bind(stationsController)
});
// GET /api/admin/users/:userId/stations - Get user's saved stations
fastify.get('/admin/users/:userId/stations', {
preHandler: [fastify.requireAdmin],
handler: stationsController.getUserSavedStations.bind(stationsController)
});
// DELETE /api/admin/users/:userId/stations/:stationId - Remove user's saved station (soft delete by default, ?force=true for hard delete)
fastify.delete('/admin/users/:userId/stations/:stationId', {
preHandler: [fastify.requireAdmin],
handler: stationsController.removeUserSavedStation.bind(stationsController)
});
};

View File

@@ -0,0 +1,24 @@
/**
* @ai-summary Request validation schemas for admin API
* @ai-context Uses Zod for runtime validation and type safety
*/
import { z } from 'zod';
export const createAdminSchema = z.object({
email: z.string().email('Invalid email format'),
role: z.enum(['admin', 'super_admin']).default('admin'),
});
export const adminAuth0SubSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'),
});
export const auditLogsQuerySchema = z.object({
limit: z.coerce.number().min(1).max(1000).default(100),
offset: z.coerce.number().min(0).default(0),
});
export type CreateAdminInput = z.infer<typeof createAdminSchema>;
export type AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>;
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;

View File

@@ -0,0 +1,539 @@
/**
* @ai-summary Catalog API controller for platform vehicle data management
* @ai-context Handles HTTP requests for CRUD operations on makes, models, years, trims, engines
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { VehicleCatalogService } from '../domain/vehicle-catalog.service';
import { logger } from '../../../core/logging/logger';
export class CatalogController {
constructor(private catalogService: VehicleCatalogService) {}
// MAKES ENDPOINTS
async getMakes(_request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const makes = await this.catalogService.getAllMakes();
reply.code(200).send({ makes });
} catch (error) {
logger.error('Error getting makes', { error });
reply.code(500).send({ error: 'Failed to retrieve makes' });
}
}
async createMake(
request: FastifyRequest<{ Body: { name: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const { name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Make name is required' });
return;
}
const make = await this.catalogService.createMake(name.trim(), actorId);
reply.code(201).send(make);
} catch (error) {
logger.error('Error creating make', { error });
reply.code(500).send({ error: 'Failed to create make' });
}
}
async updateMake(
request: FastifyRequest<{ Params: { makeId: string }; Body: { name: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const makeId = parseInt(request.params.makeId);
const { name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(makeId)) {
reply.code(400).send({ error: 'Invalid make ID' });
return;
}
if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Make name is required' });
return;
}
const make = await this.catalogService.updateMake(makeId, name.trim(), actorId);
reply.code(200).send(make);
} catch (error: any) {
logger.error('Error updating make', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to update make' });
}
}
}
async deleteMake(
request: FastifyRequest<{ Params: { makeId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const makeId = parseInt(request.params.makeId);
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(makeId)) {
reply.code(400).send({ error: 'Invalid make ID' });
return;
}
await this.catalogService.deleteMake(makeId, actorId);
reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting make', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else if (error.message?.includes('existing models')) {
reply.code(409).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to delete make' });
}
}
}
// MODELS ENDPOINTS
async getModels(
request: FastifyRequest<{ Params: { makeId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const makeId = parseInt(request.params.makeId);
if (isNaN(makeId)) {
reply.code(400).send({ error: 'Invalid make ID' });
return;
}
const models = await this.catalogService.getModelsByMake(makeId);
reply.code(200).send({ models });
} catch (error) {
logger.error('Error getting models', { error });
reply.code(500).send({ error: 'Failed to retrieve models' });
}
}
async createModel(
request: FastifyRequest<{ Body: { makeId: number; name: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const { makeId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (!makeId || !name || name.trim().length === 0) {
reply.code(400).send({ error: 'Make ID and model name are required' });
return;
}
const model = await this.catalogService.createModel(makeId, name.trim(), actorId);
reply.code(201).send(model);
} catch (error: any) {
logger.error('Error creating model', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to create model' });
}
}
}
async updateModel(
request: FastifyRequest<{ Params: { modelId: string }; Body: { makeId: number; name: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const modelId = parseInt(request.params.modelId);
const { makeId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(modelId)) {
reply.code(400).send({ error: 'Invalid model ID' });
return;
}
if (!makeId || !name || name.trim().length === 0) {
reply.code(400).send({ error: 'Make ID and model name are required' });
return;
}
const model = await this.catalogService.updateModel(modelId, makeId, name.trim(), actorId);
reply.code(200).send(model);
} catch (error: any) {
logger.error('Error updating model', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to update model' });
}
}
}
async deleteModel(
request: FastifyRequest<{ Params: { modelId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const modelId = parseInt(request.params.modelId);
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(modelId)) {
reply.code(400).send({ error: 'Invalid model ID' });
return;
}
await this.catalogService.deleteModel(modelId, actorId);
reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting model', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else if (error.message?.includes('existing years')) {
reply.code(409).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to delete model' });
}
}
}
// YEARS ENDPOINTS
async getYears(
request: FastifyRequest<{ Params: { modelId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const modelId = parseInt(request.params.modelId);
if (isNaN(modelId)) {
reply.code(400).send({ error: 'Invalid model ID' });
return;
}
const years = await this.catalogService.getYearsByModel(modelId);
reply.code(200).send({ years });
} catch (error) {
logger.error('Error getting years', { error });
reply.code(500).send({ error: 'Failed to retrieve years' });
}
}
async createYear(
request: FastifyRequest<{ Body: { modelId: number; year: number } }>,
reply: FastifyReply
): Promise<void> {
try {
const { modelId, year } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (!modelId || !year || year < 1900 || year > 2100) {
reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' });
return;
}
const yearData = await this.catalogService.createYear(modelId, year, actorId);
reply.code(201).send(yearData);
} catch (error: any) {
logger.error('Error creating year', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to create year' });
}
}
}
async updateYear(
request: FastifyRequest<{ Params: { yearId: string }; Body: { modelId: number; year: number } }>,
reply: FastifyReply
): Promise<void> {
try {
const yearId = parseInt(request.params.yearId);
const { modelId, year } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(yearId)) {
reply.code(400).send({ error: 'Invalid year ID' });
return;
}
if (!modelId || !year || year < 1900 || year > 2100) {
reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' });
return;
}
const yearData = await this.catalogService.updateYear(yearId, modelId, year, actorId);
reply.code(200).send(yearData);
} catch (error: any) {
logger.error('Error updating year', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to update year' });
}
}
}
async deleteYear(
request: FastifyRequest<{ Params: { yearId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const yearId = parseInt(request.params.yearId);
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(yearId)) {
reply.code(400).send({ error: 'Invalid year ID' });
return;
}
await this.catalogService.deleteYear(yearId, actorId);
reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting year', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else if (error.message?.includes('existing trims')) {
reply.code(409).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to delete year' });
}
}
}
// TRIMS ENDPOINTS
async getTrims(
request: FastifyRequest<{ Params: { yearId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const yearId = parseInt(request.params.yearId);
if (isNaN(yearId)) {
reply.code(400).send({ error: 'Invalid year ID' });
return;
}
const trims = await this.catalogService.getTrimsByYear(yearId);
reply.code(200).send({ trims });
} catch (error) {
logger.error('Error getting trims', { error });
reply.code(500).send({ error: 'Failed to retrieve trims' });
}
}
async createTrim(
request: FastifyRequest<{ Body: { yearId: number; name: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const { yearId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (!yearId || !name || name.trim().length === 0) {
reply.code(400).send({ error: 'Year ID and trim name are required' });
return;
}
const trim = await this.catalogService.createTrim(yearId, name.trim(), actorId);
reply.code(201).send(trim);
} catch (error: any) {
logger.error('Error creating trim', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to create trim' });
}
}
}
async updateTrim(
request: FastifyRequest<{ Params: { trimId: string }; Body: { yearId: number; name: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const trimId = parseInt(request.params.trimId);
const { yearId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(trimId)) {
reply.code(400).send({ error: 'Invalid trim ID' });
return;
}
if (!yearId || !name || name.trim().length === 0) {
reply.code(400).send({ error: 'Year ID and trim name are required' });
return;
}
const trim = await this.catalogService.updateTrim(trimId, yearId, name.trim(), actorId);
reply.code(200).send(trim);
} catch (error: any) {
logger.error('Error updating trim', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to update trim' });
}
}
}
async deleteTrim(
request: FastifyRequest<{ Params: { trimId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const trimId = parseInt(request.params.trimId);
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(trimId)) {
reply.code(400).send({ error: 'Invalid trim ID' });
return;
}
await this.catalogService.deleteTrim(trimId, actorId);
reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting trim', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else if (error.message?.includes('existing engines')) {
reply.code(409).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to delete trim' });
}
}
}
// ENGINES ENDPOINTS
async getEngines(
request: FastifyRequest<{ Params: { trimId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const trimId = parseInt(request.params.trimId);
if (isNaN(trimId)) {
reply.code(400).send({ error: 'Invalid trim ID' });
return;
}
const engines = await this.catalogService.getEnginesByTrim(trimId);
reply.code(200).send({ engines });
} catch (error) {
logger.error('Error getting engines', { error });
reply.code(500).send({ error: 'Failed to retrieve engines' });
}
}
async createEngine(
request: FastifyRequest<{ Body: { trimId: number; name: string; description?: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const { trimId, name, description } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (!trimId || !name || name.trim().length === 0) {
reply.code(400).send({ error: 'Trim ID and engine name are required' });
return;
}
const engine = await this.catalogService.createEngine(trimId, name.trim(), description, actorId);
reply.code(201).send(engine);
} catch (error: any) {
logger.error('Error creating engine', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to create engine' });
}
}
}
async updateEngine(
request: FastifyRequest<{ Params: { engineId: string }; Body: { trimId: number; name: string; description?: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const engineId = parseInt(request.params.engineId);
const { trimId, name, description } = request.body;
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(engineId)) {
reply.code(400).send({ error: 'Invalid engine ID' });
return;
}
if (!trimId || !name || name.trim().length === 0) {
reply.code(400).send({ error: 'Trim ID and engine name are required' });
return;
}
const engine = await this.catalogService.updateEngine(engineId, trimId, name.trim(), description, actorId);
reply.code(200).send(engine);
} catch (error: any) {
logger.error('Error updating engine', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to update engine' });
}
}
}
async deleteEngine(
request: FastifyRequest<{ Params: { engineId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const engineId = parseInt(request.params.engineId);
const actorId = request.userContext?.userId || 'unknown';
if (isNaN(engineId)) {
reply.code(400).send({ error: 'Invalid engine ID' });
return;
}
await this.catalogService.deleteEngine(engineId, actorId);
reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting engine', { error });
if (error.message?.includes('not found')) {
reply.code(404).send({ error: error.message });
} else {
reply.code(500).send({ error: 'Failed to delete engine' });
}
}
}
// CHANGE LOG ENDPOINT
async getChangeLogs(
request: FastifyRequest<{ Querystring: { limit?: string; offset?: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const limit = parseInt(request.query.limit || '100');
const offset = parseInt(request.query.offset || '0');
const result = await this.catalogService.getChangeLogs(limit, offset);
reply.code(200).send(result);
} catch (error) {
logger.error('Error getting change logs', { error });
reply.code(500).send({ error: 'Failed to retrieve change logs' });
}
}
}

View File

@@ -0,0 +1,231 @@
/**
* @ai-summary HTTP request handlers for admin station oversight
* @ai-context Handles admin operations on global stations and user-saved stations
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { StationOversightService } from '../domain/station-oversight.service';
import { logger } from '../../../core/logging/logger';
interface StationListQuery {
limit?: string;
offset?: string;
search?: string;
}
interface CreateStationBody {
placeId: string;
name: string;
address: string;
latitude: number;
longitude: number;
priceRegular?: number;
pricePremium?: number;
priceDiesel?: number;
rating?: number;
photoUrl?: string;
}
interface UpdateStationBody {
name?: string;
address?: string;
latitude?: number;
longitude?: number;
priceRegular?: number;
pricePremium?: number;
priceDiesel?: number;
rating?: number;
photoUrl?: string;
}
interface StationParams {
stationId: string;
}
interface UserStationParams {
userId: string;
stationId: string;
}
interface DeleteQuery {
force?: string;
}
export class StationsController {
constructor(private service: StationOversightService) {}
/**
* GET /api/admin/stations
* List all stations globally with pagination and search
*/
async listAllStations(
request: FastifyRequest<{ Querystring: StationListQuery }>,
reply: FastifyReply
): Promise<void> {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
const limit = request.query.limit ? parseInt(request.query.limit, 10) : 100;
const offset = request.query.offset ? parseInt(request.query.offset, 10) : 0;
const search = request.query.search;
const result = await this.service.listAllStations(limit, offset, search);
return reply.code(200).send(result);
} catch (error) {
logger.error('Error listing stations', { error });
return reply.code(500).send({ error: 'Failed to list stations' });
}
}
/**
* POST /api/admin/stations
* Create a new station
*/
async createStation(
request: FastifyRequest<{ Body: CreateStationBody }>,
reply: FastifyReply
): Promise<void> {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
// Validate required fields
const { placeId, name, address, latitude, longitude } = request.body;
if (!placeId || !name || !address || latitude === undefined || longitude === undefined) {
return reply.code(400).send({ error: 'Missing required fields: placeId, name, address, latitude, longitude' });
}
const station = await this.service.createStation(actorId, request.body);
return reply.code(201).send(station);
} catch (error: any) {
logger.error('Error creating station', { error });
if (error.message?.includes('duplicate key')) {
return reply.code(409).send({ error: 'Station with this placeId already exists' });
}
return reply.code(500).send({ error: 'Failed to create station' });
}
}
/**
* PUT /api/admin/stations/:stationId
* Update an existing station
*/
async updateStation(
request: FastifyRequest<{ Params: StationParams; Body: UpdateStationBody }>,
reply: FastifyReply
): Promise<void> {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
const { stationId } = request.params;
// Validate at least one field to update
if (Object.keys(request.body).length === 0) {
return reply.code(400).send({ error: 'No fields to update' });
}
const station = await this.service.updateStation(actorId, stationId, request.body);
return reply.code(200).send(station);
} catch (error: any) {
logger.error('Error updating station', { error });
if (error.message === 'Station not found') {
return reply.code(404).send({ error: 'Station not found' });
}
return reply.code(500).send({ error: 'Failed to update station' });
}
}
/**
* DELETE /api/admin/stations/:stationId
* Delete a station (soft delete by default, hard delete with ?force=true)
*/
async deleteStation(
request: FastifyRequest<{ Params: StationParams; Querystring: DeleteQuery }>,
reply: FastifyReply
): Promise<void> {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
const { stationId } = request.params;
const force = request.query.force === 'true';
await this.service.deleteStation(actorId, stationId, force);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting station', { error });
if (error.message === 'Station not found') {
return reply.code(404).send({ error: 'Station not found' });
}
return reply.code(500).send({ error: 'Failed to delete station' });
}
}
/**
* GET /api/admin/users/:userId/stations
* Get user's saved stations
*/
async getUserSavedStations(
request: FastifyRequest<{ Params: { userId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
const { userId } = request.params;
const stations = await this.service.getUserSavedStations(userId);
return reply.code(200).send(stations);
} catch (error) {
logger.error('Error getting user saved stations', { error });
return reply.code(500).send({ error: 'Failed to get user saved stations' });
}
}
/**
* DELETE /api/admin/users/:userId/stations/:stationId
* Remove user's saved station (soft delete by default, hard delete with ?force=true)
*/
async removeUserSavedStation(
request: FastifyRequest<{ Params: UserStationParams; Querystring: DeleteQuery }>,
reply: FastifyReply
): Promise<void> {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
const { userId, stationId } = request.params;
const force = request.query.force === 'true';
await this.service.removeUserSavedStation(actorId, userId, stationId, force);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error removing user saved station', { error });
if (error.message?.includes('not found')) {
return reply.code(404).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to remove user saved station' });
}
}
}