Admin User v1
This commit is contained in:
399
backend/src/features/admin/api/admin.controller.ts
Normal file
399
backend/src/features/admin/api/admin.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
222
backend/src/features/admin/api/admin.routes.ts
Normal file
222
backend/src/features/admin/api/admin.routes.ts
Normal 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)
|
||||
});
|
||||
};
|
||||
24
backend/src/features/admin/api/admin.validation.ts
Normal file
24
backend/src/features/admin/api/admin.validation.ts
Normal 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>;
|
||||
539
backend/src/features/admin/api/catalog.controller.ts
Normal file
539
backend/src/features/admin/api/catalog.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
231
backend/src/features/admin/api/stations.controller.ts
Normal file
231
backend/src/features/admin/api/stations.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user