Notification updates
This commit is contained in:
@@ -23,6 +23,8 @@ const MIGRATION_ORDER = [
|
||||
'features/maintenance', // Depends on vehicles
|
||||
'features/stations', // Independent
|
||||
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
|
||||
'features/notifications', // Depends on maintenance and documents
|
||||
'features/user-profile', // User profile management; independent
|
||||
];
|
||||
|
||||
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
||||
@@ -101,7 +103,7 @@ async function main() {
|
||||
// Wait for database to be reachable (handles cold starts)
|
||||
const waitForDb = async (timeoutMs = 60000) => {
|
||||
const start = Date.now();
|
||||
/* eslint-disable no-constant-condition */
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
|
||||
@@ -23,6 +23,8 @@ import { documentsRoutes } from './features/documents/api/documents.routes';
|
||||
import { maintenanceRoutes } from './features/maintenance';
|
||||
import { platformRoutes } from './features/platform';
|
||||
import { adminRoutes } from './features/admin/api/admin.routes';
|
||||
import { notificationsRoutes } from './features/notifications';
|
||||
import { userProfileRoutes } from './features/user-profile';
|
||||
import { pool } from './core/config/database';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
@@ -80,7 +82,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform']
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +92,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform']
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +126,8 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(communityStationsRoutes, { prefix: '/api' });
|
||||
await app.register(maintenanceRoutes, { prefix: '/api' });
|
||||
await app.register(adminRoutes, { prefix: '/api' });
|
||||
await app.register(notificationsRoutes, { prefix: '/api' });
|
||||
await app.register(userProfileRoutes, { prefix: '/api' });
|
||||
|
||||
// 404 handler
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
|
||||
@@ -123,6 +123,7 @@ const secretsSchema = z.object({
|
||||
postgres_password: z.string(),
|
||||
auth0_client_secret: z.string(),
|
||||
google_maps_api_key: z.string(),
|
||||
resend_api_key: z.string(),
|
||||
});
|
||||
|
||||
type Config = z.infer<typeof configSchema>;
|
||||
@@ -171,6 +172,7 @@ class ConfigurationLoader {
|
||||
'postgres-password',
|
||||
'auth0-client-secret',
|
||||
'google-maps-api-key',
|
||||
'resend-api-key',
|
||||
];
|
||||
|
||||
for (const secretFile of secretFiles) {
|
||||
@@ -227,6 +229,9 @@ class ConfigurationLoader {
|
||||
},
|
||||
};
|
||||
|
||||
// Set RESEND_API_KEY in environment for EmailService
|
||||
process.env['RESEND_API_KEY'] = secrets.resend_api_key;
|
||||
|
||||
logger.info('Configuration loaded successfully', {
|
||||
configSource: 'yaml',
|
||||
secretsSource: 'files',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* @ai-summary Fastify JWT authentication plugin using Auth0
|
||||
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint
|
||||
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile
|
||||
*/
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import buildGetJwks from 'get-jwks';
|
||||
import { appConfig } from '../config/config-loader';
|
||||
import { logger } from '../logging/logger';
|
||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||
import { pool } from '../config/database';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -18,6 +20,7 @@ declare module 'fastify' {
|
||||
userContext?: {
|
||||
userId: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
isAdmin: boolean;
|
||||
adminRecord?: any;
|
||||
};
|
||||
@@ -70,21 +73,51 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize profile repository for user profile hydration
|
||||
const profileRepo = new UserProfileRepository(pool);
|
||||
|
||||
// Decorate with authenticate function that validates JWT
|
||||
fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
|
||||
// Hydrate userContext with basic auth info
|
||||
const userId = request.user?.sub;
|
||||
|
||||
// Get or create user profile from database
|
||||
// This ensures we have reliable email/displayName for notifications
|
||||
let email = request.user?.email;
|
||||
let displayName: string | undefined;
|
||||
|
||||
try {
|
||||
const profile = await profileRepo.getOrCreate(userId, {
|
||||
email: request.user?.email || `${userId}@unknown.local`,
|
||||
displayName: request.user?.name || request.user?.nickname,
|
||||
});
|
||||
|
||||
// Use notificationEmail if set, otherwise fall back to profile email
|
||||
email = profile.notificationEmail || profile.email;
|
||||
displayName = profile.displayName || undefined;
|
||||
} catch (profileError) {
|
||||
// Log but don't fail auth if profile fetch fails
|
||||
logger.warn('Failed to fetch user profile', {
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
error: profileError instanceof Error ? profileError.message : 'Unknown error',
|
||||
});
|
||||
// Fall back to JWT email if available
|
||||
email = request.user?.email;
|
||||
}
|
||||
|
||||
// Hydrate userContext with profile data
|
||||
request.userContext = {
|
||||
userId,
|
||||
email: request.user?.email,
|
||||
email,
|
||||
displayName,
|
||||
isAdmin: false, // Default to false; admin status checked by admin guard
|
||||
};
|
||||
|
||||
logger.info('JWT authentication successful', {
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
hasEmail: !!email,
|
||||
audience: auth0Config.audience
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { UsersController } from './users.controller';
|
||||
import {
|
||||
CreateAdminInput,
|
||||
AdminAuth0SubInput,
|
||||
@@ -15,6 +16,14 @@ import {
|
||||
BulkDeleteCatalogInput,
|
||||
CatalogEntity
|
||||
} from './admin.validation';
|
||||
import {
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
PromoteToAdminInput,
|
||||
} from './users.validation';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { StationOversightService } from '../domain/station-oversight.service';
|
||||
import { StationsController } from './stations.controller';
|
||||
@@ -28,6 +37,7 @@ import { CommunityStationsController } from '../../stations/api/community-statio
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const adminController = new AdminController();
|
||||
const usersController = new UsersController();
|
||||
|
||||
// Initialize station oversight dependencies
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
@@ -99,6 +109,52 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: adminController.bulkReinstateAdmins.bind(adminController)
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// User Management endpoints (subscription tiers, deactivation)
|
||||
// ============================================
|
||||
|
||||
// GET /api/admin/users - List all users with pagination and filters
|
||||
fastify.get<{ Querystring: ListUsersQueryInput }>('/admin/users', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.listUsers.bind(usersController)
|
||||
});
|
||||
|
||||
// GET /api/admin/users/:auth0Sub - Get single user details
|
||||
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.getUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.updateTier.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>('/admin/users/:auth0Sub/deactivate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.deactivateUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
||||
fastify.patch<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/reactivate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.reactivateUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>('/admin/users/:auth0Sub/profile', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.updateProfile.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>('/admin/users/:auth0Sub/promote', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.promoteToAdmin.bind(usersController)
|
||||
});
|
||||
|
||||
// Phase 3: Catalog CRUD endpoints
|
||||
|
||||
// Makes endpoints
|
||||
|
||||
489
backend/src/features/admin/api/users.controller.ts
Normal file
489
backend/src/features/admin/api/users.controller.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for admin user management API
|
||||
* @ai-context HTTP request/response handling for managing all application users (not just admins)
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserProfileService } from '../../user-profile/domain/user-profile.service';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
listUsersQuerySchema,
|
||||
userAuth0SubSchema,
|
||||
updateTierSchema,
|
||||
deactivateUserSchema,
|
||||
updateProfileSchema,
|
||||
promoteToAdminSchema,
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
PromoteToAdminInput,
|
||||
} from './users.validation';
|
||||
import { AdminService } from '../domain/admin.service';
|
||||
|
||||
export class UsersController {
|
||||
private userProfileService: UserProfileService;
|
||||
private adminService: AdminService;
|
||||
|
||||
constructor() {
|
||||
const userProfileRepository = new UserProfileRepository(pool);
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
|
||||
this.userProfileService = new UserProfileService(userProfileRepository);
|
||||
this.userProfileService.setAdminRepository(adminRepository);
|
||||
this.adminService = new AdminService(adminRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/users - List all users with pagination and filters
|
||||
*/
|
||||
async listUsers(
|
||||
request: FastifyRequest<{ Querystring: ListUsersQueryInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and parse query params
|
||||
const parseResult = listUsersQuerySchema.safeParse(request.query);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: parseResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const query = parseResult.data;
|
||||
const result = await this.userProfileService.listAllUsers(query);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error('Error listing users', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to list users',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:auth0Sub - Get single user details
|
||||
*/
|
||||
async getUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const parseResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: parseResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = parseResult.data;
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(user);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user details', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get user details',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||
*/
|
||||
async updateTier(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyResult = updateTierSchema.safeParse(request.body);
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { subscriptionTier } = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.updateSubscriptionTier(
|
||||
auth0Sub,
|
||||
subscriptionTier,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(updatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error updating user tier', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update subscription tier',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
||||
*/
|
||||
async deactivateUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body (optional)
|
||||
const bodyResult = deactivateUserSchema.safeParse(request.body || {});
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { reason } = bodyResult.data;
|
||||
|
||||
const deactivatedUser = await this.userProfileService.deactivateUser(
|
||||
auth0Sub,
|
||||
actorId,
|
||||
reason
|
||||
);
|
||||
|
||||
return reply.code(200).send(deactivatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error deactivating user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'Cannot deactivate your own account') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'User is already deactivated') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to deactivate user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
||||
*/
|
||||
async reactivateUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
|
||||
const reactivatedUser = await this.userProfileService.reactivateUser(
|
||||
auth0Sub,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(reactivatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error reactivating user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'User is not deactivated') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to reactivate user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
||||
*/
|
||||
async updateProfile(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyResult = updateProfileSchema.safeParse(request.body);
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const updates = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.adminUpdateProfile(
|
||||
auth0Sub,
|
||||
updates,
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(200).send(updatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error updating user profile', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update user profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
||||
*/
|
||||
async promoteToAdmin(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyResult = promoteToAdminSchema.safeParse(request.body || {});
|
||||
if (!bodyResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: bodyResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { role } = bodyResult.data;
|
||||
|
||||
// Get the user profile first to verify they exist and get their email
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
if (!user) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is already an admin
|
||||
if (user.isAdmin) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: 'User is already an admin',
|
||||
});
|
||||
}
|
||||
|
||||
// Create the admin record using the user's real auth0Sub
|
||||
const adminUser = await this.adminService.createAdmin(
|
||||
user.email,
|
||||
role,
|
||||
auth0Sub, // Use the real auth0Sub from the user profile
|
||||
actorId
|
||||
);
|
||||
|
||||
return reply.code(201).send(adminUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error promoting user to admin', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage.includes('already exists')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to promote user to admin',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
57
backend/src/features/admin/api/users.validation.ts
Normal file
57
backend/src/features/admin/api/users.validation.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for admin user management API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Subscription tier enum
|
||||
export const subscriptionTierSchema = z.enum(['free', 'pro', 'enterprise']);
|
||||
|
||||
// Query params for listing users
|
||||
export const listUsersQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
pageSize: z.coerce.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
tier: subscriptionTierSchema.optional(),
|
||||
status: z.enum(['active', 'deactivated', 'all']).default('all'),
|
||||
sortBy: z.enum(['email', 'createdAt', 'displayName', 'subscriptionTier']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
// Path param for user auth0Sub
|
||||
export const userAuth0SubSchema = z.object({
|
||||
auth0Sub: z.string().min(1, 'auth0Sub is required'),
|
||||
});
|
||||
|
||||
// Body for updating subscription tier
|
||||
export const updateTierSchema = z.object({
|
||||
subscriptionTier: subscriptionTierSchema,
|
||||
});
|
||||
|
||||
// Body for deactivating a user
|
||||
export const deactivateUserSchema = z.object({
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// Body for updating user profile (admin edit)
|
||||
export const updateProfileSchema = z.object({
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional(),
|
||||
}).refine(
|
||||
(data) => data.email !== undefined || data.displayName !== undefined,
|
||||
{ message: 'At least one field (email or displayName) must be provided' }
|
||||
);
|
||||
|
||||
// Body for promoting user to admin
|
||||
export const promoteToAdminSchema = z.object({
|
||||
role: z.enum(['admin', 'super_admin']).default('admin'),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
|
||||
export type UserAuth0SubInput = z.infer<typeof userAuth0SubSchema>;
|
||||
export type UpdateTierInput = z.infer<typeof updateTierSchema>;
|
||||
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
export type PromoteToAdminInput = z.infer<typeof promoteToAdminSchema>;
|
||||
@@ -243,7 +243,8 @@ export class AdminRepository {
|
||||
action: row.action,
|
||||
resourceType: row.resource_type,
|
||||
resourceId: row.resource_id,
|
||||
context: row.context ? JSON.parse(row.context) : undefined,
|
||||
// JSONB columns are automatically parsed by pg driver - no JSON.parse needed
|
||||
context: row.context || undefined,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Documents list requested', {
|
||||
operation: 'documents.list',
|
||||
user_id: userId,
|
||||
userId,
|
||||
filters: {
|
||||
vehicle_id: request.query.vehicleId,
|
||||
vehicleId: request.query.vehicleId,
|
||||
type: request.query.type,
|
||||
expires_before: request.query.expiresBefore,
|
||||
expiresBefore: request.query.expiresBefore,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,8 +33,8 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Documents list retrieved', {
|
||||
operation: 'documents.list.success',
|
||||
user_id: userId,
|
||||
document_count: docs.length,
|
||||
userId,
|
||||
documentCount: docs.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(docs);
|
||||
@@ -46,26 +46,26 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document get requested', {
|
||||
operation: 'documents.get',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = await this.service.getDocument(userId, documentId);
|
||||
if (!doc) {
|
||||
logger.warn('Document not found', {
|
||||
operation: 'documents.get.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Document retrieved', {
|
||||
operation: 'documents.get.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: doc.vehicle_id,
|
||||
document_type: doc.document_type,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: doc.vehicleId,
|
||||
documentType: doc.documentType,
|
||||
});
|
||||
|
||||
return reply.code(200).send(doc);
|
||||
@@ -76,9 +76,9 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document create requested', {
|
||||
operation: 'documents.create',
|
||||
user_id: userId,
|
||||
vehicle_id: request.body.vehicle_id,
|
||||
document_type: request.body.document_type,
|
||||
userId,
|
||||
vehicleId: request.body.vehicleId,
|
||||
documentType: request.body.documentType,
|
||||
title: request.body.title,
|
||||
});
|
||||
|
||||
@@ -86,10 +86,10 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document created', {
|
||||
operation: 'documents.create.success',
|
||||
user_id: userId,
|
||||
document_id: created.id,
|
||||
vehicle_id: created.vehicle_id,
|
||||
document_type: created.document_type,
|
||||
userId,
|
||||
documentId: created.id,
|
||||
vehicleId: created.vehicleId,
|
||||
documentType: created.documentType,
|
||||
title: created.title,
|
||||
});
|
||||
|
||||
@@ -102,26 +102,26 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document update requested', {
|
||||
operation: 'documents.update',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
update_fields: Object.keys(request.body),
|
||||
userId,
|
||||
documentId,
|
||||
updateFields: Object.keys(request.body),
|
||||
});
|
||||
|
||||
const updated = await this.service.updateDocument(userId, documentId, request.body);
|
||||
if (!updated) {
|
||||
logger.warn('Document not found for update', {
|
||||
operation: 'documents.update.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Document updated', {
|
||||
operation: 'documents.update.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: updated.vehicle_id,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: updated.vehicleId,
|
||||
title: updated.title,
|
||||
});
|
||||
|
||||
@@ -134,28 +134,28 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document delete requested', {
|
||||
operation: 'documents.delete',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
// If object exists, delete it from storage first
|
||||
const existing = await this.service.getDocument(userId, documentId);
|
||||
if (existing && existing.storage_bucket && existing.storage_key) {
|
||||
if (existing && existing.storageBucket && existing.storageKey) {
|
||||
const storage = getStorageService();
|
||||
try {
|
||||
await storage.deleteObject(existing.storage_bucket, existing.storage_key);
|
||||
await storage.deleteObject(existing.storageBucket, existing.storageKey);
|
||||
logger.info('Document file deleted from storage', {
|
||||
operation: 'documents.delete.storage_cleanup',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
storage_key: existing.storage_key,
|
||||
userId,
|
||||
documentId,
|
||||
storageKey: existing.storageKey,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('Failed to delete document file from storage', {
|
||||
operation: 'documents.delete.storage_cleanup_failed',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
storage_key: existing.storage_key,
|
||||
userId,
|
||||
documentId,
|
||||
storageKey: existing.storageKey,
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
// Non-fatal: proceed with soft delete
|
||||
@@ -166,10 +166,10 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document deleted', {
|
||||
operation: 'documents.delete.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: existing?.vehicle_id,
|
||||
had_file: !!(existing?.storage_key),
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: existing?.vehicleId,
|
||||
hadFile: !!(existing?.storageKey),
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
@@ -181,16 +181,16 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document upload requested', {
|
||||
operation: 'documents.upload',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = await this.service.getDocument(userId, documentId);
|
||||
if (!doc) {
|
||||
logger.warn('Document not found for upload', {
|
||||
operation: 'documents.upload.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
@@ -199,8 +199,8 @@ export class DocumentsController {
|
||||
if (!mp) {
|
||||
logger.warn('No file provided for upload', {
|
||||
operation: 'documents.upload.no_file',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
|
||||
}
|
||||
@@ -216,10 +216,10 @@ export class DocumentsController {
|
||||
if (!contentType || !allowedTypes.has(contentType)) {
|
||||
logger.warn('Unsupported file type for upload (header validation)', {
|
||||
operation: 'documents.upload.unsupported_type',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
content_type: contentType,
|
||||
file_name: mp.filename,
|
||||
userId,
|
||||
documentId,
|
||||
contentType,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
@@ -248,10 +248,10 @@ export class DocumentsController {
|
||||
if (!detectedType) {
|
||||
logger.warn('Unable to detect file type from content', {
|
||||
operation: 'documents.upload.type_detection_failed',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
content_type: contentType,
|
||||
file_name: mp.filename,
|
||||
userId,
|
||||
documentId,
|
||||
contentType,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
@@ -264,11 +264,11 @@ export class DocumentsController {
|
||||
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
|
||||
logger.warn('File content does not match Content-Type header', {
|
||||
operation: 'documents.upload.type_mismatch',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
claimed_type: contentType,
|
||||
detected_type: detectedType.mime,
|
||||
file_name: mp.filename,
|
||||
userId,
|
||||
documentId,
|
||||
claimedType: contentType,
|
||||
detectedType: detectedType.mime,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
@@ -310,29 +310,29 @@ export class DocumentsController {
|
||||
const bucket = 'documents';
|
||||
const version = 'v1';
|
||||
const unique = cryptoRandom();
|
||||
const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`;
|
||||
const key = `documents/${userId}/${doc.vehicleId}/${doc.id}/${version}/${unique}.${ext}`;
|
||||
|
||||
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
|
||||
|
||||
const updated = await this.service['repo'].updateStorageMeta(doc.id, userId, {
|
||||
storage_bucket: bucket,
|
||||
storage_key: key,
|
||||
file_name: originalName,
|
||||
content_type: contentType,
|
||||
file_size: counter.bytes,
|
||||
file_hash: null,
|
||||
storageBucket: bucket,
|
||||
storageKey: key,
|
||||
fileName: originalName,
|
||||
contentType: contentType,
|
||||
fileSize: counter.bytes,
|
||||
fileHash: null,
|
||||
});
|
||||
|
||||
logger.info('Document upload completed', {
|
||||
operation: 'documents.upload.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: doc.vehicle_id,
|
||||
file_name: originalName,
|
||||
content_type: contentType,
|
||||
detected_type: detectedType.mime,
|
||||
file_size: counter.bytes,
|
||||
storage_key: key,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: doc.vehicleId,
|
||||
fileName: originalName,
|
||||
contentType,
|
||||
detectedType: detectedType.mime,
|
||||
fileSize: counter.bytes,
|
||||
storageKey: key,
|
||||
});
|
||||
|
||||
return reply.code(200).send(updated);
|
||||
@@ -344,18 +344,18 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document download requested', {
|
||||
operation: 'documents.download',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = await this.service.getDocument(userId, documentId);
|
||||
if (!doc || !doc.storage_bucket || !doc.storage_key) {
|
||||
if (!doc || !doc.storageBucket || !doc.storageKey) {
|
||||
logger.warn('Document or file not found for download', {
|
||||
operation: 'documents.download.not_found',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
has_document: !!doc,
|
||||
has_storage_info: !!(doc?.storage_bucket && doc?.storage_key),
|
||||
userId,
|
||||
documentId,
|
||||
hasDocument: !!doc,
|
||||
hasStorageInfo: !!(doc?.storageBucket && doc?.storageKey),
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
@@ -363,10 +363,10 @@ export class DocumentsController {
|
||||
const storage = getStorageService();
|
||||
let head: Partial<import('../../../core/storage/storage.service').HeadObjectResult> = {};
|
||||
try {
|
||||
head = await storage.headObject(doc.storage_bucket, doc.storage_key);
|
||||
head = await storage.headObject(doc.storageBucket, doc.storageKey);
|
||||
} catch { /* ignore */ }
|
||||
const contentType = head.contentType || doc.content_type || 'application/octet-stream';
|
||||
const filename = doc.file_name || path.basename(doc.storage_key);
|
||||
const contentType = head.contentType || doc.contentType || 'application/octet-stream';
|
||||
const filename = doc.fileName || path.basename(doc.storageKey);
|
||||
const inlineTypes = new Set(['application/pdf', 'image/jpeg', 'image/png']);
|
||||
const disposition = inlineTypes.has(contentType) ? 'inline' : 'attachment';
|
||||
|
||||
@@ -375,16 +375,16 @@ export class DocumentsController {
|
||||
|
||||
logger.info('Document download initiated', {
|
||||
operation: 'documents.download.success',
|
||||
user_id: userId,
|
||||
document_id: documentId,
|
||||
vehicle_id: doc.vehicle_id,
|
||||
file_name: filename,
|
||||
content_type: contentType,
|
||||
disposition: disposition,
|
||||
file_size: head.size || doc.file_size,
|
||||
userId,
|
||||
documentId,
|
||||
vehicleId: doc.vehicleId,
|
||||
fileName: filename,
|
||||
contentType,
|
||||
disposition,
|
||||
fileSize: head.size || doc.fileSize,
|
||||
});
|
||||
|
||||
const stream = await storage.getObjectStream(doc.storage_bucket, doc.storage_key);
|
||||
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
||||
return reply.send(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,40 +5,74 @@ import type { DocumentRecord, DocumentType } from '../domain/documents.types';
|
||||
export class DocumentsRepository {
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
// ========================
|
||||
// Row Mapper
|
||||
// ========================
|
||||
|
||||
private mapDocumentRecord(row: any): DocumentRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
documentType: row.document_type,
|
||||
title: row.title,
|
||||
notes: row.notes,
|
||||
details: row.details,
|
||||
storageBucket: row.storage_bucket,
|
||||
storageKey: row.storage_key,
|
||||
fileName: row.file_name,
|
||||
contentType: row.content_type,
|
||||
fileSize: row.file_size,
|
||||
fileHash: row.file_hash,
|
||||
issuedDate: row.issued_date,
|
||||
expirationDate: row.expiration_date,
|
||||
emailNotifications: row.email_notifications,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// CRUD Operations
|
||||
// ========================
|
||||
|
||||
async insert(doc: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
details?: any;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
}): Promise<DocumentRecord> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO documents (
|
||||
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING *`,
|
||||
[
|
||||
doc.id,
|
||||
doc.user_id,
|
||||
doc.vehicle_id,
|
||||
doc.document_type,
|
||||
doc.userId,
|
||||
doc.vehicleId,
|
||||
doc.documentType,
|
||||
doc.title,
|
||||
doc.notes ?? null,
|
||||
doc.details ?? null,
|
||||
doc.issued_date ?? null,
|
||||
doc.expiration_date ?? null,
|
||||
doc.issuedDate ?? null,
|
||||
doc.expirationDate ?? null,
|
||||
doc.emailNotifications ?? false,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as DocumentRecord;
|
||||
return this.mapDocumentRecord(res.rows[0]);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<DocumentRecord | null> {
|
||||
const res = await this.db.query(`SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, [id, userId]);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async listByUser(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }): Promise<DocumentRecord[]> {
|
||||
@@ -50,31 +84,32 @@ export class DocumentsRepository {
|
||||
if (filters?.expiresBefore) { conds.push(`expiration_date <= $${i++}`); params.push(filters.expiresBefore); }
|
||||
const sql = `SELECT * FROM documents WHERE ${conds.join(' AND ')} ORDER BY created_at DESC`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows as DocumentRecord[];
|
||||
return res.rows.map(row => this.mapDocumentRecord(row));
|
||||
}
|
||||
|
||||
async softDelete(id: string, userId: string): Promise<void> {
|
||||
await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]);
|
||||
}
|
||||
|
||||
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issued_date'|'expiration_date'>>): Promise<DocumentRecord | null> {
|
||||
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'>>): Promise<DocumentRecord | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let i = 1;
|
||||
if (patch.title !== undefined) { fields.push(`title = $${i++}`); params.push(patch.title); }
|
||||
if (patch.notes !== undefined) { fields.push(`notes = $${i++}`); params.push(patch.notes); }
|
||||
if (patch.details !== undefined) { fields.push(`details = $${i++}`); params.push(patch.details); }
|
||||
if (patch.issued_date !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issued_date); }
|
||||
if (patch.expiration_date !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expiration_date); }
|
||||
if (patch.issuedDate !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issuedDate); }
|
||||
if (patch.expirationDate !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expirationDate); }
|
||||
if (patch.emailNotifications !== undefined) { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); }
|
||||
if (!fields.length) return this.findById(id, userId);
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateStorageMeta(id: string, userId: string, meta: {
|
||||
storage_bucket: string; storage_key: string; file_name: string; content_type: string; file_size: number; file_hash?: string | null;
|
||||
storageBucket: string; storageKey: string; fileName: string; contentType: string; fileSize: number; fileHash?: string | null;
|
||||
}): Promise<DocumentRecord | null> {
|
||||
const res = await this.db.query(
|
||||
`UPDATE documents SET
|
||||
@@ -86,9 +121,9 @@ export class DocumentsRepository {
|
||||
file_hash = $6
|
||||
WHERE id = $7 AND user_id = $8 AND deleted_at IS NULL
|
||||
RETURNING *`,
|
||||
[meta.storage_bucket, meta.storage_key, meta.file_name, meta.content_type, meta.file_size, meta.file_hash ?? null, id, userId]
|
||||
[meta.storageBucket, meta.storageKey, meta.fileName, meta.contentType, meta.fileSize, meta.fileHash ?? null, id, userId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,19 @@ export class DocumentsService {
|
||||
private readonly repo = new DocumentsRepository(pool);
|
||||
|
||||
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
||||
await this.assertVehicleOwnership(userId, body.vehicle_id);
|
||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||
const id = randomUUID();
|
||||
return this.repo.insert({
|
||||
id,
|
||||
user_id: userId,
|
||||
vehicle_id: body.vehicle_id,
|
||||
document_type: body.document_type as DocumentType,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
documentType: body.documentType as DocumentType,
|
||||
title: body.title,
|
||||
notes: body.notes ?? null,
|
||||
details: body.details ?? null,
|
||||
issued_date: body.issued_date ?? null,
|
||||
expiration_date: body.expiration_date ?? null,
|
||||
issuedDate: body.issuedDate ?? null,
|
||||
expirationDate: body.expirationDate ?? null,
|
||||
emailNotifications: body.emailNotifications ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,35 +3,39 @@ import { z } from 'zod';
|
||||
export const DocumentTypeSchema = z.enum(['insurance', 'registration']);
|
||||
export type DocumentType = z.infer<typeof DocumentTypeSchema>;
|
||||
|
||||
// API response type (camelCase for frontend)
|
||||
export interface DocumentRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
details?: Record<string, any> | null;
|
||||
storage_bucket?: string | null;
|
||||
storage_key?: string | null;
|
||||
file_name?: string | null;
|
||||
content_type?: string | null;
|
||||
file_size?: number | null;
|
||||
file_hash?: string | null;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
storageBucket?: string | null;
|
||||
storageKey?: string | null;
|
||||
fileName?: string | null;
|
||||
contentType?: string | null;
|
||||
fileSize?: number | null;
|
||||
fileHash?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
// API request schemas (camelCase for frontend)
|
||||
export const CreateDocumentBodySchema = z.object({
|
||||
vehicle_id: z.string().uuid(),
|
||||
document_type: DocumentTypeSchema,
|
||||
vehicleId: z.string().uuid(),
|
||||
documentType: DocumentTypeSchema,
|
||||
title: z.string().min(1).max(200),
|
||||
notes: z.string().max(10000).optional(),
|
||||
details: z.record(z.any()).optional(),
|
||||
issued_date: z.string().optional(),
|
||||
expiration_date: z.string().optional(),
|
||||
issuedDate: z.string().optional(),
|
||||
expirationDate: z.string().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
|
||||
|
||||
@@ -39,8 +43,9 @@ export const UpdateDocumentBodySchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
notes: z.string().max(10000).nullable().optional(),
|
||||
details: z.record(z.any()).optional(),
|
||||
issued_date: z.string().nullable().optional(),
|
||||
expiration_date: z.string().nullable().optional(),
|
||||
issuedDate: z.string().nullable().optional(),
|
||||
expirationDate: z.string().nullable().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ export class FuelLogsRepository {
|
||||
data.notes ?? null
|
||||
];
|
||||
const res = await this.pool.query(query, values);
|
||||
return res.rows[0];
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
|
||||
@@ -255,7 +255,7 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows;
|
||||
return res.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByUserIdEnhanced(userId: string): Promise<any[]> {
|
||||
@@ -263,12 +263,12 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows;
|
||||
return res.rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByIdEnhanced(id: string): Promise<any | null> {
|
||||
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
|
||||
@@ -276,7 +276,7 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
|
||||
[vehicleId, odometerReading]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
|
||||
@@ -284,7 +284,7 @@ export class FuelLogsRepository {
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateEnhanced(id: string, data: {
|
||||
@@ -370,6 +370,6 @@ export class FuelLogsRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records list requested', {
|
||||
operation: 'maintenance.records.list',
|
||||
user_id: userId,
|
||||
userId,
|
||||
filters: {
|
||||
vehicle_id: request.query.vehicleId,
|
||||
vehicleId: request.query.vehicleId,
|
||||
category: request.query.category,
|
||||
},
|
||||
});
|
||||
@@ -42,15 +42,15 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records list retrieved', {
|
||||
operation: 'maintenance.records.list.success',
|
||||
user_id: userId,
|
||||
record_count: records.length,
|
||||
userId,
|
||||
recordCount: records.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(records);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list maintenance records', {
|
||||
operation: 'maintenance.records.list.error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -63,8 +63,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record get requested', {
|
||||
operation: 'maintenance.records.get',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -72,17 +72,17 @@ export class MaintenanceController {
|
||||
if (!record) {
|
||||
logger.warn('Maintenance record not found', {
|
||||
operation: 'maintenance.records.get.not_found',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Maintenance record retrieved', {
|
||||
operation: 'maintenance.records.get.success',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
vehicle_id: record.vehicle_id,
|
||||
userId,
|
||||
recordId,
|
||||
vehicleId: record.vehicleId,
|
||||
category: record.category,
|
||||
});
|
||||
|
||||
@@ -90,8 +90,8 @@ export class MaintenanceController {
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance record', {
|
||||
operation: 'maintenance.records.get.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -107,8 +107,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records by vehicle requested', {
|
||||
operation: 'maintenance.records.by_vehicle',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -116,17 +116,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance records by vehicle retrieved', {
|
||||
operation: 'maintenance.records.by_vehicle.success',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
record_count: records.length,
|
||||
userId,
|
||||
vehicleId,
|
||||
recordCount: records.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(records);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance records by vehicle', {
|
||||
operation: 'maintenance.records.by_vehicle.error',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -138,7 +138,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record create requested', {
|
||||
operation: 'maintenance.records.create',
|
||||
user_id: userId,
|
||||
userId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -148,11 +148,11 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record created', {
|
||||
operation: 'maintenance.records.create.success',
|
||||
user_id: userId,
|
||||
record_id: record.id,
|
||||
vehicle_id: record.vehicle_id,
|
||||
userId,
|
||||
recordId: record.id,
|
||||
vehicleId: record.vehicleId,
|
||||
category: record.category,
|
||||
subtype_count: record.subtypes.length,
|
||||
subtypeCount: record.subtypes.length,
|
||||
});
|
||||
|
||||
return reply.code(201).send(record);
|
||||
@@ -160,7 +160,7 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance record validation failed', {
|
||||
operation: 'maintenance.records.create.validation_error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -170,8 +170,8 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance record creation failed', {
|
||||
operation: 'maintenance.records.create.error',
|
||||
user_id: userId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -179,7 +179,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to create maintenance record', {
|
||||
operation: 'maintenance.records.create.error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -195,8 +195,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record update requested', {
|
||||
operation: 'maintenance.records.update',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -206,17 +206,17 @@ export class MaintenanceController {
|
||||
if (!record) {
|
||||
logger.warn('Maintenance record not found for update', {
|
||||
operation: 'maintenance.records.update.not_found',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Maintenance record updated', {
|
||||
operation: 'maintenance.records.update.success',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
vehicle_id: record.vehicle_id,
|
||||
userId,
|
||||
recordId,
|
||||
vehicleId: record.vehicleId,
|
||||
category: record.category,
|
||||
});
|
||||
|
||||
@@ -225,8 +225,8 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance record update validation failed', {
|
||||
operation: 'maintenance.records.update.validation_error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -236,9 +236,9 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance record update failed', {
|
||||
operation: 'maintenance.records.update.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
recordId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -246,8 +246,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to update maintenance record', {
|
||||
operation: 'maintenance.records.update.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -260,8 +260,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record delete requested', {
|
||||
operation: 'maintenance.records.delete',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -269,16 +269,16 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance record deleted', {
|
||||
operation: 'maintenance.records.delete.success',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete maintenance record', {
|
||||
operation: 'maintenance.records.delete.error',
|
||||
user_id: userId,
|
||||
record_id: recordId,
|
||||
userId,
|
||||
recordId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -294,8 +294,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedules by vehicle requested', {
|
||||
operation: 'maintenance.schedules.by_vehicle',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -303,17 +303,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedules by vehicle retrieved', {
|
||||
operation: 'maintenance.schedules.by_vehicle.success',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
schedule_count: schedules.length,
|
||||
userId,
|
||||
vehicleId,
|
||||
scheduleCount: schedules.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(schedules);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance schedules by vehicle', {
|
||||
operation: 'maintenance.schedules.by_vehicle.error',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -325,7 +325,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule create requested', {
|
||||
operation: 'maintenance.schedules.create',
|
||||
user_id: userId,
|
||||
userId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -335,11 +335,11 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule created', {
|
||||
operation: 'maintenance.schedules.create.success',
|
||||
user_id: userId,
|
||||
schedule_id: schedule.id,
|
||||
vehicle_id: schedule.vehicle_id,
|
||||
userId,
|
||||
scheduleId: schedule.id,
|
||||
vehicleId: schedule.vehicleId,
|
||||
category: schedule.category,
|
||||
subtype_count: schedule.subtypes.length,
|
||||
subtypeCount: schedule.subtypes.length,
|
||||
});
|
||||
|
||||
return reply.code(201).send(schedule);
|
||||
@@ -347,7 +347,7 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance schedule validation failed', {
|
||||
operation: 'maintenance.schedules.create.validation_error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -357,8 +357,8 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance schedule creation failed', {
|
||||
operation: 'maintenance.schedules.create.error',
|
||||
user_id: userId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -366,7 +366,7 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to create maintenance schedule', {
|
||||
operation: 'maintenance.schedules.create.error',
|
||||
user_id: userId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -382,8 +382,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule update requested', {
|
||||
operation: 'maintenance.schedules.update',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -393,17 +393,17 @@ export class MaintenanceController {
|
||||
if (!schedule) {
|
||||
logger.warn('Maintenance schedule not found for update', {
|
||||
operation: 'maintenance.schedules.update.not_found',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found' });
|
||||
}
|
||||
|
||||
logger.info('Maintenance schedule updated', {
|
||||
operation: 'maintenance.schedules.update.success',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
vehicle_id: schedule.vehicle_id,
|
||||
userId,
|
||||
scheduleId,
|
||||
vehicleId: schedule.vehicleId,
|
||||
category: schedule.category,
|
||||
});
|
||||
|
||||
@@ -412,8 +412,8 @@ export class MaintenanceController {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Maintenance schedule update validation failed', {
|
||||
operation: 'maintenance.schedules.update.validation_error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
errors: error.errors,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
|
||||
@@ -423,9 +423,9 @@ export class MaintenanceController {
|
||||
const statusCode = (error as any).statusCode;
|
||||
logger.warn('Maintenance schedule update failed', {
|
||||
operation: 'maintenance.schedules.update.error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
status_code: statusCode,
|
||||
userId,
|
||||
scheduleId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(statusCode).send({ error: error.message });
|
||||
@@ -433,8 +433,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.error('Failed to update maintenance schedule', {
|
||||
operation: 'maintenance.schedules.update.error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -447,8 +447,8 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule delete requested', {
|
||||
operation: 'maintenance.schedules.delete',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -456,16 +456,16 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance schedule deleted', {
|
||||
operation: 'maintenance.schedules.delete.success',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete maintenance schedule', {
|
||||
operation: 'maintenance.schedules.delete.error',
|
||||
user_id: userId,
|
||||
schedule_id: scheduleId,
|
||||
userId,
|
||||
scheduleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -482,9 +482,9 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Upcoming maintenance requested', {
|
||||
operation: 'maintenance.upcoming',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
current_mileage: currentMileage,
|
||||
userId,
|
||||
vehicleId,
|
||||
currentMileage,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -492,17 +492,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Upcoming maintenance retrieved', {
|
||||
operation: 'maintenance.upcoming.success',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
upcoming_count: upcoming.length,
|
||||
userId,
|
||||
vehicleId,
|
||||
upcomingCount: upcoming.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send(upcoming);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get upcoming maintenance', {
|
||||
operation: 'maintenance.upcoming.error',
|
||||
user_id: userId,
|
||||
vehicle_id: vehicleId,
|
||||
userId,
|
||||
vehicleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
@@ -515,16 +515,16 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance subtypes requested', {
|
||||
operation: 'maintenance.subtypes',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
userId,
|
||||
category,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!['routine_maintenance', 'repair', 'performance_upgrade'].includes(category)) {
|
||||
logger.warn('Invalid maintenance category', {
|
||||
operation: 'maintenance.subtypes.invalid_category',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
userId,
|
||||
category,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', message: 'Invalid category' });
|
||||
}
|
||||
@@ -533,17 +533,17 @@ export class MaintenanceController {
|
||||
|
||||
logger.info('Maintenance subtypes retrieved', {
|
||||
operation: 'maintenance.subtypes.success',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
subtype_count: subtypes.length,
|
||||
userId,
|
||||
category,
|
||||
subtypeCount: subtypes.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ category, subtypes: Array.from(subtypes) });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get maintenance subtypes', {
|
||||
operation: 'maintenance.subtypes.error',
|
||||
user_id: userId,
|
||||
category: category,
|
||||
userId,
|
||||
category,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
|
||||
@@ -5,20 +5,61 @@ import type { MaintenanceRecord, MaintenanceSchedule, MaintenanceCategory } from
|
||||
export class MaintenanceRepository {
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
// ========================
|
||||
// Row Mappers
|
||||
// ========================
|
||||
|
||||
private mapMaintenanceRecord(row: any): MaintenanceRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
category: row.category,
|
||||
subtypes: row.subtypes,
|
||||
date: row.date,
|
||||
odometerReading: row.odometer_reading,
|
||||
cost: row.cost,
|
||||
shopName: row.shop_name,
|
||||
notes: row.notes,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
private mapMaintenanceSchedule(row: any): MaintenanceSchedule {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
category: row.category,
|
||||
subtypes: row.subtypes,
|
||||
intervalMonths: row.interval_months,
|
||||
intervalMiles: row.interval_miles,
|
||||
lastServiceDate: row.last_service_date,
|
||||
lastServiceMileage: row.last_service_mileage,
|
||||
nextDueDate: row.next_due_date,
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isActive: row.is_active,
|
||||
emailNotifications: row.email_notifications,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Maintenance Records
|
||||
// ========================
|
||||
|
||||
async insertRecord(record: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number | null;
|
||||
odometerReading?: number | null;
|
||||
cost?: number | null;
|
||||
shop_name?: string | null;
|
||||
shopName?: string | null;
|
||||
notes?: string | null;
|
||||
}): Promise<MaintenanceRecord> {
|
||||
const res = await this.db.query(
|
||||
@@ -28,18 +69,18 @@ export class MaintenanceRepository {
|
||||
RETURNING *`,
|
||||
[
|
||||
record.id,
|
||||
record.user_id,
|
||||
record.vehicle_id,
|
||||
record.userId,
|
||||
record.vehicleId,
|
||||
record.category,
|
||||
record.subtypes,
|
||||
record.date,
|
||||
record.odometer_reading ?? null,
|
||||
record.odometerReading ?? null,
|
||||
record.cost ?? null,
|
||||
record.shop_name ?? null,
|
||||
record.shopName ?? null,
|
||||
record.notes ?? null,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as MaintenanceRecord;
|
||||
return this.mapMaintenanceRecord(res.rows[0]);
|
||||
}
|
||||
|
||||
async findRecordById(id: string, userId: string): Promise<MaintenanceRecord | null> {
|
||||
@@ -47,7 +88,7 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_records WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findRecordsByUserId(
|
||||
@@ -69,7 +110,7 @@ export class MaintenanceRepository {
|
||||
|
||||
const sql = `SELECT * FROM maintenance_records WHERE ${conds.join(' AND ')} ORDER BY date DESC`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows as MaintenanceRecord[];
|
||||
return res.rows.map(row => this.mapMaintenanceRecord(row));
|
||||
}
|
||||
|
||||
async findRecordsByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceRecord[]> {
|
||||
@@ -77,13 +118,13 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_records WHERE vehicle_id = $1 AND user_id = $2 ORDER BY date DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows as MaintenanceRecord[];
|
||||
return res.rows.map(row => this.mapMaintenanceRecord(row));
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometer_reading' | 'cost' | 'shop_name' | 'notes'>>
|
||||
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometerReading' | 'cost' | 'shopName' | 'notes'>>
|
||||
): Promise<MaintenanceRecord | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -101,17 +142,17 @@ export class MaintenanceRepository {
|
||||
fields.push(`date = $${i++}`);
|
||||
params.push(patch.date);
|
||||
}
|
||||
if (patch.odometer_reading !== undefined) {
|
||||
if (patch.odometerReading !== undefined) {
|
||||
fields.push(`odometer_reading = $${i++}`);
|
||||
params.push(patch.odometer_reading);
|
||||
params.push(patch.odometerReading);
|
||||
}
|
||||
if (patch.cost !== undefined) {
|
||||
fields.push(`cost = $${i++}`);
|
||||
params.push(patch.cost);
|
||||
}
|
||||
if (patch.shop_name !== undefined) {
|
||||
if (patch.shopName !== undefined) {
|
||||
fields.push(`shop_name = $${i++}`);
|
||||
params.push(patch.shop_name);
|
||||
params.push(patch.shopName);
|
||||
}
|
||||
if (patch.notes !== undefined) {
|
||||
fields.push(`notes = $${i++}`);
|
||||
@@ -123,7 +164,7 @@ export class MaintenanceRepository {
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE maintenance_records SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async deleteRecord(id: string, userId: string): Promise<void> {
|
||||
@@ -139,40 +180,42 @@ export class MaintenanceRepository {
|
||||
|
||||
async insertSchedule(schedule: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number | null;
|
||||
interval_miles?: number | null;
|
||||
last_service_date?: string | null;
|
||||
last_service_mileage?: number | null;
|
||||
next_due_date?: string | null;
|
||||
next_due_mileage?: number | null;
|
||||
is_active: boolean;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
lastServiceDate?: string | null;
|
||||
lastServiceMileage?: number | null;
|
||||
nextDueDate?: string | null;
|
||||
nextDueMileage?: number | null;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
}): Promise<MaintenanceSchedule> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO maintenance_schedules (
|
||||
id, user_id, vehicle_id, category, subtypes, interval_months, interval_miles,
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12)
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
schedule.id,
|
||||
schedule.user_id,
|
||||
schedule.vehicle_id,
|
||||
schedule.userId,
|
||||
schedule.vehicleId,
|
||||
schedule.category,
|
||||
schedule.subtypes,
|
||||
schedule.interval_months ?? null,
|
||||
schedule.interval_miles ?? null,
|
||||
schedule.last_service_date ?? null,
|
||||
schedule.last_service_mileage ?? null,
|
||||
schedule.next_due_date ?? null,
|
||||
schedule.next_due_mileage ?? null,
|
||||
schedule.is_active,
|
||||
schedule.intervalMonths ?? null,
|
||||
schedule.intervalMiles ?? null,
|
||||
schedule.lastServiceDate ?? null,
|
||||
schedule.lastServiceMileage ?? null,
|
||||
schedule.nextDueDate ?? null,
|
||||
schedule.nextDueMileage ?? null,
|
||||
schedule.isActive,
|
||||
schedule.emailNotifications ?? false,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as MaintenanceSchedule;
|
||||
return this.mapMaintenanceSchedule(res.rows[0]);
|
||||
}
|
||||
|
||||
async findScheduleById(id: string, userId: string): Promise<MaintenanceSchedule | null> {
|
||||
@@ -180,7 +223,7 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_schedules WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceSchedule(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
|
||||
@@ -188,7 +231,7 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 ORDER BY created_at DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows as MaintenanceSchedule[];
|
||||
return res.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
|
||||
async findActiveSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
|
||||
@@ -196,13 +239,13 @@ export class MaintenanceRepository {
|
||||
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 AND is_active = true ORDER BY created_at DESC`,
|
||||
[vehicleId, userId]
|
||||
);
|
||||
return res.rows as MaintenanceSchedule[];
|
||||
return res.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'interval_months' | 'interval_miles' | 'last_service_date' | 'last_service_mileage' | 'next_due_date' | 'next_due_mileage' | 'is_active'>>
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications'>>
|
||||
): Promise<MaintenanceSchedule | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -216,33 +259,37 @@ export class MaintenanceRepository {
|
||||
fields.push(`subtypes = $${i++}::text[]`);
|
||||
params.push(patch.subtypes);
|
||||
}
|
||||
if (patch.interval_months !== undefined) {
|
||||
if (patch.intervalMonths !== undefined) {
|
||||
fields.push(`interval_months = $${i++}`);
|
||||
params.push(patch.interval_months);
|
||||
params.push(patch.intervalMonths);
|
||||
}
|
||||
if (patch.interval_miles !== undefined) {
|
||||
if (patch.intervalMiles !== undefined) {
|
||||
fields.push(`interval_miles = $${i++}`);
|
||||
params.push(patch.interval_miles);
|
||||
params.push(patch.intervalMiles);
|
||||
}
|
||||
if (patch.last_service_date !== undefined) {
|
||||
if (patch.lastServiceDate !== undefined) {
|
||||
fields.push(`last_service_date = $${i++}`);
|
||||
params.push(patch.last_service_date);
|
||||
params.push(patch.lastServiceDate);
|
||||
}
|
||||
if (patch.last_service_mileage !== undefined) {
|
||||
if (patch.lastServiceMileage !== undefined) {
|
||||
fields.push(`last_service_mileage = $${i++}`);
|
||||
params.push(patch.last_service_mileage);
|
||||
params.push(patch.lastServiceMileage);
|
||||
}
|
||||
if (patch.next_due_date !== undefined) {
|
||||
if (patch.nextDueDate !== undefined) {
|
||||
fields.push(`next_due_date = $${i++}`);
|
||||
params.push(patch.next_due_date);
|
||||
params.push(patch.nextDueDate);
|
||||
}
|
||||
if (patch.next_due_mileage !== undefined) {
|
||||
if (patch.nextDueMileage !== undefined) {
|
||||
fields.push(`next_due_mileage = $${i++}`);
|
||||
params.push(patch.next_due_mileage);
|
||||
params.push(patch.nextDueMileage);
|
||||
}
|
||||
if (patch.is_active !== undefined) {
|
||||
if (patch.isActive !== undefined) {
|
||||
fields.push(`is_active = $${i++}`);
|
||||
params.push(patch.is_active);
|
||||
params.push(patch.isActive);
|
||||
}
|
||||
if (patch.emailNotifications !== undefined) {
|
||||
fields.push(`email_notifications = $${i++}`);
|
||||
params.push(patch.emailNotifications);
|
||||
}
|
||||
|
||||
if (!fields.length) return this.findScheduleById(id, userId);
|
||||
@@ -250,7 +297,7 @@ export class MaintenanceRepository {
|
||||
params.push(id, userId);
|
||||
const sql = `UPDATE maintenance_schedules SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] || null;
|
||||
return res.rows[0] ? this.mapMaintenanceSchedule(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string, userId: string): Promise<void> {
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MaintenanceService {
|
||||
private readonly repo = new MaintenanceRepository(pool);
|
||||
|
||||
async createRecord(userId: string, body: CreateMaintenanceRecordRequest): Promise<MaintenanceRecord> {
|
||||
await this.assertVehicleOwnership(userId, body.vehicle_id);
|
||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||
|
||||
if (!validateSubtypes(body.category, body.subtypes)) {
|
||||
const err: any = new Error('Invalid subtypes for selected category');
|
||||
@@ -29,14 +29,14 @@ export class MaintenanceService {
|
||||
const id = randomUUID();
|
||||
return this.repo.insertRecord({
|
||||
id,
|
||||
user_id: userId,
|
||||
vehicle_id: body.vehicle_id,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
category: body.category,
|
||||
subtypes: body.subtypes,
|
||||
date: body.date,
|
||||
odometer_reading: body.odometer_reading,
|
||||
odometerReading: body.odometerReading,
|
||||
cost: body.cost,
|
||||
shop_name: body.shop_name,
|
||||
shopName: body.shopName,
|
||||
notes: body.notes,
|
||||
});
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export class MaintenanceService {
|
||||
// Convert nulls to undefined for repository compatibility
|
||||
const cleanPatch = Object.fromEntries(
|
||||
Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v])
|
||||
) as Partial<Pick<MaintenanceRecord, 'date' | 'notes' | 'category' | 'subtypes' | 'odometer_reading' | 'cost' | 'shop_name'>>;
|
||||
) as Partial<Pick<MaintenanceRecord, 'date' | 'notes' | 'category' | 'subtypes' | 'odometerReading' | 'cost' | 'shopName'>>;
|
||||
|
||||
const updated = await this.repo.updateRecord(id, userId, cleanPatch);
|
||||
if (!updated) return null;
|
||||
@@ -86,7 +86,7 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
async createSchedule(userId: string, body: CreateScheduleRequest): Promise<MaintenanceSchedule> {
|
||||
await this.assertVehicleOwnership(userId, body.vehicle_id);
|
||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||
|
||||
if (!validateSubtypes(body.category, body.subtypes)) {
|
||||
const err: any = new Error('Invalid subtypes for selected category');
|
||||
@@ -94,7 +94,7 @@ export class MaintenanceService {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!body.interval_months && !body.interval_miles) {
|
||||
if (!body.intervalMonths && !body.intervalMiles) {
|
||||
const err: any = new Error('At least one interval (months or miles) is required');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
@@ -103,13 +103,14 @@ export class MaintenanceService {
|
||||
const id = randomUUID();
|
||||
return this.repo.insertSchedule({
|
||||
id,
|
||||
user_id: userId,
|
||||
vehicle_id: body.vehicle_id,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
category: body.category,
|
||||
subtypes: body.subtypes,
|
||||
interval_months: body.interval_months,
|
||||
interval_miles: body.interval_miles,
|
||||
is_active: true,
|
||||
intervalMonths: body.intervalMonths,
|
||||
intervalMiles: body.intervalMiles,
|
||||
isActive: true,
|
||||
emailNotifications: body.emailNotifications ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,25 +144,25 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
const needsRecalculation =
|
||||
patch.interval_months !== undefined ||
|
||||
patch.interval_miles !== undefined;
|
||||
patch.intervalMonths !== undefined ||
|
||||
patch.intervalMiles !== undefined;
|
||||
|
||||
let patchWithRecalc: any = { ...patch };
|
||||
const patchWithRecalc: any = { ...patch };
|
||||
if (needsRecalculation) {
|
||||
const nextDue = this.calculateNextDue({
|
||||
last_service_date: existing.last_service_date,
|
||||
last_service_mileage: existing.last_service_mileage,
|
||||
interval_months: patch.interval_months ?? existing.interval_months,
|
||||
interval_miles: patch.interval_miles ?? existing.interval_miles,
|
||||
lastServiceDate: existing.lastServiceDate,
|
||||
lastServiceMileage: existing.lastServiceMileage,
|
||||
intervalMonths: patch.intervalMonths ?? existing.intervalMonths,
|
||||
intervalMiles: patch.intervalMiles ?? existing.intervalMiles,
|
||||
});
|
||||
patchWithRecalc.next_due_date = nextDue.next_due_date ?? undefined;
|
||||
patchWithRecalc.next_due_mileage = nextDue.next_due_mileage ?? undefined;
|
||||
patchWithRecalc.nextDueDate = nextDue.nextDueDate ?? undefined;
|
||||
patchWithRecalc.nextDueMileage = nextDue.nextDueMileage ?? undefined;
|
||||
}
|
||||
|
||||
// Convert nulls to undefined for repository compatibility
|
||||
const cleanPatch = Object.fromEntries(
|
||||
Object.entries(patchWithRecalc).map(([k, v]) => [k, v === null ? undefined : v])
|
||||
) as Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'interval_months' | 'interval_miles' | 'is_active' | 'last_service_date' | 'last_service_mileage' | 'next_due_date' | 'next_due_mileage'>>;
|
||||
) as Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'isActive' | 'emailNotifications' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage'>>;
|
||||
|
||||
const updated = await this.repo.updateSchedule(id, userId, cleanPatch);
|
||||
if (!updated) return null;
|
||||
@@ -178,7 +179,7 @@ export class MaintenanceService {
|
||||
|
||||
return schedules
|
||||
.map(s => this.toScheduleResponse(s, today, currentMileage))
|
||||
.filter(s => s.is_due_soon || s.is_overdue);
|
||||
.filter(s => s.isDueSoon || s.isOverdue);
|
||||
}
|
||||
|
||||
private async assertVehicleOwnership(userId: string, vehicleId: string) {
|
||||
@@ -191,66 +192,66 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
private calculateNextDue(schedule: {
|
||||
last_service_date?: string | null;
|
||||
last_service_mileage?: number | null;
|
||||
interval_months?: number | null;
|
||||
interval_miles?: number | null;
|
||||
}): { next_due_date: string | null; next_due_mileage: number | null } {
|
||||
let next_due_date: string | null = null;
|
||||
let next_due_mileage: number | null = null;
|
||||
lastServiceDate?: string | null;
|
||||
lastServiceMileage?: number | null;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
}): { nextDueDate: string | null; nextDueMileage: number | null } {
|
||||
let nextDueDate: string | null = null;
|
||||
let nextDueMileage: number | null = null;
|
||||
|
||||
if (schedule.last_service_date && schedule.interval_months) {
|
||||
const lastDate = new Date(schedule.last_service_date);
|
||||
if (schedule.lastServiceDate && schedule.intervalMonths) {
|
||||
const lastDate = new Date(schedule.lastServiceDate);
|
||||
const nextDate = new Date(lastDate);
|
||||
nextDate.setMonth(nextDate.getMonth() + schedule.interval_months);
|
||||
next_due_date = nextDate.toISOString().split('T')[0];
|
||||
nextDate.setMonth(nextDate.getMonth() + schedule.intervalMonths);
|
||||
nextDueDate = nextDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (schedule.last_service_mileage !== null && schedule.last_service_mileage !== undefined && schedule.interval_miles) {
|
||||
next_due_mileage = schedule.last_service_mileage + schedule.interval_miles;
|
||||
if (schedule.lastServiceMileage !== null && schedule.lastServiceMileage !== undefined && schedule.intervalMiles) {
|
||||
nextDueMileage = schedule.lastServiceMileage + schedule.intervalMiles;
|
||||
}
|
||||
|
||||
return { next_due_date, next_due_mileage };
|
||||
return { nextDueDate, nextDueMileage };
|
||||
}
|
||||
|
||||
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
|
||||
return {
|
||||
...record,
|
||||
subtype_count: record.subtypes.length,
|
||||
subtypeCount: record.subtypes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private toScheduleResponse(schedule: MaintenanceSchedule, today?: string, currentMileage?: number): MaintenanceScheduleResponse {
|
||||
const todayStr = today || new Date().toISOString().split('T')[0];
|
||||
let is_due_soon = false;
|
||||
let is_overdue = false;
|
||||
let isDueSoon = false;
|
||||
let isOverdue = false;
|
||||
|
||||
if (schedule.next_due_date) {
|
||||
const nextDue = new Date(schedule.next_due_date);
|
||||
if (schedule.nextDueDate) {
|
||||
const nextDue = new Date(schedule.nextDueDate);
|
||||
const todayDate = new Date(todayStr);
|
||||
const daysUntilDue = Math.floor((nextDue.getTime() - todayDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilDue < 0) {
|
||||
is_overdue = true;
|
||||
isOverdue = true;
|
||||
} else if (daysUntilDue <= 30) {
|
||||
is_due_soon = true;
|
||||
isDueSoon = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMileage !== undefined && schedule.next_due_mileage !== null && schedule.next_due_mileage !== undefined) {
|
||||
const milesUntilDue = schedule.next_due_mileage - currentMileage;
|
||||
if (currentMileage !== undefined && schedule.nextDueMileage !== null && schedule.nextDueMileage !== undefined) {
|
||||
const milesUntilDue = schedule.nextDueMileage - currentMileage;
|
||||
if (milesUntilDue < 0) {
|
||||
is_overdue = true;
|
||||
isOverdue = true;
|
||||
} else if (milesUntilDue <= 500) {
|
||||
is_due_soon = true;
|
||||
isDueSoon = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
subtype_count: schedule.subtypes.length,
|
||||
is_due_soon,
|
||||
is_overdue,
|
||||
subtypeCount: schedule.subtypes.length,
|
||||
isDueSoon,
|
||||
isOverdue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,50 +55,51 @@ export const PERFORMANCE_UPGRADE_SUBTYPES = [
|
||||
'Exterior'
|
||||
] as const;
|
||||
|
||||
// Database record types
|
||||
// Database record types (camelCase for TypeScript)
|
||||
export interface MaintenanceRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number;
|
||||
odometerReading?: number;
|
||||
cost?: number;
|
||||
shop_name?: string;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceSchedule {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number;
|
||||
interval_miles?: number;
|
||||
last_service_date?: string;
|
||||
last_service_mileage?: number;
|
||||
next_due_date?: string;
|
||||
next_due_mileage?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
lastServiceDate?: string;
|
||||
lastServiceMileage?: number;
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Zod schemas for validation
|
||||
// Zod schemas for validation (camelCase for API)
|
||||
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
|
||||
|
||||
export const CreateMaintenanceRecordSchema = z.object({
|
||||
vehicle_id: z.string().uuid(),
|
||||
vehicleId: z.string().uuid(),
|
||||
category: MaintenanceCategorySchema,
|
||||
subtypes: z.array(z.string()).min(1),
|
||||
date: z.string(),
|
||||
odometer_reading: z.number().int().positive().optional(),
|
||||
odometerReading: z.number().int().positive().optional(),
|
||||
cost: z.number().positive().optional(),
|
||||
shop_name: z.string().max(200).optional(),
|
||||
shopName: z.string().max(200).optional(),
|
||||
notes: z.string().max(10000).optional(),
|
||||
});
|
||||
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
|
||||
@@ -107,40 +108,42 @@ export const UpdateMaintenanceRecordSchema = z.object({
|
||||
category: MaintenanceCategorySchema.optional(),
|
||||
subtypes: z.array(z.string()).min(1).optional(),
|
||||
date: z.string().optional(),
|
||||
odometer_reading: z.number().int().positive().nullable().optional(),
|
||||
odometerReading: z.number().int().positive().nullable().optional(),
|
||||
cost: z.number().positive().nullable().optional(),
|
||||
shop_name: z.string().max(200).nullable().optional(),
|
||||
shopName: z.string().max(200).nullable().optional(),
|
||||
notes: z.string().max(10000).nullable().optional(),
|
||||
});
|
||||
export type UpdateMaintenanceRecordRequest = z.infer<typeof UpdateMaintenanceRecordSchema>;
|
||||
|
||||
export const CreateScheduleSchema = z.object({
|
||||
vehicle_id: z.string().uuid(),
|
||||
vehicleId: z.string().uuid(),
|
||||
category: MaintenanceCategorySchema,
|
||||
subtypes: z.array(z.string()).min(1),
|
||||
interval_months: z.number().int().positive().optional(),
|
||||
interval_miles: z.number().int().positive().optional(),
|
||||
intervalMonths: z.number().int().positive().optional(),
|
||||
intervalMiles: z.number().int().positive().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type CreateScheduleRequest = z.infer<typeof CreateScheduleSchema>;
|
||||
|
||||
export const UpdateScheduleSchema = z.object({
|
||||
category: MaintenanceCategorySchema.optional(),
|
||||
subtypes: z.array(z.string()).min(1).optional(),
|
||||
interval_months: z.number().int().positive().nullable().optional(),
|
||||
interval_miles: z.number().int().positive().nullable().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
intervalMonths: z.number().int().positive().nullable().optional(),
|
||||
intervalMiles: z.number().int().positive().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
// Response types
|
||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||
subtype_count: number;
|
||||
subtypeCount: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
|
||||
subtype_count: number;
|
||||
is_due_soon?: boolean;
|
||||
is_overdue?: boolean;
|
||||
subtypeCount: number;
|
||||
isDueSoon?: boolean;
|
||||
isOverdue?: boolean;
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
121
backend/src/features/notifications/README.md
Normal file
121
backend/src/features/notifications/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Notifications Feature Capsule
|
||||
|
||||
## Quick Summary
|
||||
|
||||
Email and toast notification system for maintenance due/overdue items and expiring documents. Uses Resend for email delivery and provides admin-editable email templates. User-scoped data with per-entry email notification toggles.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### User Endpoints
|
||||
- `GET /api/notifications/summary` - Get notification summary (counts for login toast)
|
||||
- `GET /api/notifications/maintenance` - Get due/overdue maintenance items
|
||||
- `GET /api/notifications/documents` - Get expiring/expired documents
|
||||
|
||||
### Admin Endpoints
|
||||
- `GET /api/admin/email-templates` - List all email templates
|
||||
- `GET /api/admin/email-templates/:key` - Get single email template
|
||||
- `PUT /api/admin/email-templates/:key` - Update email template
|
||||
- `POST /api/admin/email-templates/:key/preview` - Preview template with sample variables
|
||||
|
||||
## Structure
|
||||
|
||||
- **api/** - HTTP endpoints, routes, validators
|
||||
- **domain/** - Business logic, services, types
|
||||
- **data/** - Repository, database queries
|
||||
- **migrations/** - Feature-specific schema
|
||||
- **tests/** - All feature tests
|
||||
|
||||
## Email Templates
|
||||
|
||||
### Predefined Templates (4 total)
|
||||
1. **maintenance_due_soon** - Sent when maintenance is due within 30 days or 500 miles
|
||||
2. **maintenance_overdue** - Sent when maintenance is past due
|
||||
3. **document_expiring** - Sent when document expires within 30 days
|
||||
4. **document_expired** - Sent when document has expired
|
||||
|
||||
### Template Variables
|
||||
Templates use `{{variableName}}` syntax for variable substitution.
|
||||
|
||||
**Maintenance templates:**
|
||||
- userName, vehicleName, category, subtypes, dueDate, dueMileage
|
||||
|
||||
**Document templates:**
|
||||
- userName, vehicleName, documentType, documentTitle, expirationDate
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `core/auth` - Authentication plugin
|
||||
- `core/logging` - Structured logging
|
||||
- `core/config` - Database pool and secrets
|
||||
|
||||
### External
|
||||
- `resend` - Email delivery service
|
||||
|
||||
### Database
|
||||
- Tables: `email_templates`, `notification_logs`
|
||||
- FK: `maintenance_schedules(email_notifications)`, `documents(email_notifications)`
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Notification Triggers
|
||||
|
||||
**Maintenance Due Soon:**
|
||||
- Next due date within 30 days OR
|
||||
- Next due mileage within 500 miles of current odometer
|
||||
|
||||
**Maintenance Overdue:**
|
||||
- Next due date in the past OR
|
||||
- Current odometer exceeds next due mileage
|
||||
|
||||
**Document Expiring Soon:**
|
||||
- Expiration date within 30 days
|
||||
|
||||
**Document Expired:**
|
||||
- Expiration date in the past
|
||||
|
||||
### Email Notification Toggle
|
||||
- Per-entry toggle on `maintenance_schedules.email_notifications`
|
||||
- Per-entry toggle on `documents.email_notifications`
|
||||
- Default: `false` (opt-in)
|
||||
|
||||
### Login Toast Summary
|
||||
- Shows count of maintenance items requiring attention
|
||||
- Shows count of documents requiring attention
|
||||
- Displayed once per session on successful login
|
||||
|
||||
## Security Requirements
|
||||
|
||||
1. All queries user-scoped (filter by user_id)
|
||||
2. Prepared statements (never concatenate SQL)
|
||||
3. User endpoints require JWT authentication
|
||||
4. Admin endpoints require admin role
|
||||
5. Template editing restricted to admins
|
||||
6. Email logs track all sent notifications
|
||||
|
||||
## Email Service Configuration
|
||||
|
||||
### Environment Variables
|
||||
- `RESEND_API_KEY` - Resend API key (required, stored in secrets)
|
||||
- `FROM_EMAIL` - Sender email address (default: noreply@motovaultpro.com)
|
||||
|
||||
### Email Delivery
|
||||
- Uses Resend API for transactional emails
|
||||
- Converts plain text templates to HTML with line breaks
|
||||
- Tracks all sent emails in `notification_logs` table
|
||||
- Logs failures with error messages for debugging
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run feature tests
|
||||
npm test -- features/notifications
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Batch notification processing (scheduled job)
|
||||
- Notification frequency controls (daily digest, etc.)
|
||||
- User preference for notification types
|
||||
- SMS notifications (via Twilio or similar)
|
||||
- Push notifications (via FCM or similar)
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @ai-summary Controller for notifications API endpoints
|
||||
* @ai-context Handles requests for notification summary, templates, and sending
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { NotificationsService } from '../domain/notifications.service';
|
||||
import type { TemplateKey } from '../domain/notifications.types';
|
||||
import type {
|
||||
TemplateKeyParam,
|
||||
UpdateEmailTemplateRequest,
|
||||
PreviewTemplateRequest
|
||||
} from './notifications.validation';
|
||||
|
||||
export class NotificationsController {
|
||||
private service: NotificationsService;
|
||||
|
||||
constructor(service?: NotificationsService) {
|
||||
this.service = service || new NotificationsService();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// User Endpoints
|
||||
// ========================
|
||||
|
||||
async getSummary(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
|
||||
try {
|
||||
const summary = await this.service.getNotificationSummary(userId);
|
||||
return reply.code(200).send(summary);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get notification summary');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get notification summary'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
|
||||
try {
|
||||
const items = await this.service.getDueMaintenanceItems(userId);
|
||||
return reply.code(200).send(items);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get due maintenance items');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get due maintenance items'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
|
||||
try {
|
||||
const documents = await this.service.getExpiringDocuments(userId);
|
||||
return reply.code(200).send(documents);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get expiring documents');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get expiring documents'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Admin Endpoints
|
||||
// ========================
|
||||
|
||||
async listTemplates(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const templates = await this.service.listTemplates();
|
||||
return reply.code(200).send(templates);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to list email templates');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to list email templates'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplate(
|
||||
request: FastifyRequest<{ Params: TemplateKeyParam }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { key } = request.params;
|
||||
|
||||
try {
|
||||
const template = await this.service.getTemplate(key as TemplateKey);
|
||||
|
||||
if (!template) {
|
||||
return reply.code(404).send({
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(template);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get email template');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to get email template'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
request: FastifyRequest<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: UpdateEmailTemplateRequest;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { key } = request.params;
|
||||
const updates = request.body;
|
||||
|
||||
try {
|
||||
const template = await this.service.updateTemplate(key as TemplateKey, updates);
|
||||
|
||||
if (!template) {
|
||||
return reply.code(404).send({
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(template);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to update email template');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to update email template'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async previewTemplate(
|
||||
request: FastifyRequest<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: PreviewTemplateRequest;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { subject, body, variables } = request.body;
|
||||
|
||||
try {
|
||||
const preview = await this.service.previewTemplate(subject, body, variables);
|
||||
return reply.code(200).send(preview);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to preview template');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to preview template'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail(
|
||||
request: FastifyRequest<{ Params: TemplateKeyParam }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { key } = request.params;
|
||||
const userEmail = request.userContext?.email;
|
||||
const userName = request.userContext?.displayName || 'Test User';
|
||||
|
||||
if (!userEmail) {
|
||||
return reply.code(400).send({
|
||||
error: 'No email address available. Please set your email in Settings.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.service.sendTestEmail(
|
||||
key as any,
|
||||
userEmail,
|
||||
userName
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.code(500).send({
|
||||
error: result.error || 'Failed to send test email',
|
||||
subject: result.subject,
|
||||
body: result.body
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
message: `Test email sent to ${userEmail}`,
|
||||
subject: result.subject,
|
||||
body: result.body
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to send test email');
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to send test email'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @ai-summary Notifications feature routes
|
||||
* @ai-context Registers notification API endpoints with proper guards
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import type {
|
||||
TemplateKeyParam,
|
||||
UpdateEmailTemplateRequest,
|
||||
PreviewTemplateRequest
|
||||
} from './notifications.validation';
|
||||
|
||||
export const notificationsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new NotificationsController();
|
||||
|
||||
// ========================
|
||||
// User Endpoints
|
||||
// ========================
|
||||
|
||||
// GET /api/notifications/summary - Get notification summary for login toast
|
||||
fastify.get('/notifications/summary', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getSummary.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/notifications/maintenance - Get due maintenance items
|
||||
fastify.get('/notifications/maintenance', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getDueMaintenanceItems.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/notifications/documents - Get expiring documents
|
||||
fastify.get('/notifications/documents', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getExpiringDocuments.bind(controller)
|
||||
});
|
||||
|
||||
// ========================
|
||||
// Admin Endpoints
|
||||
// ========================
|
||||
|
||||
// GET /api/admin/email-templates - List all email templates
|
||||
fastify.get('/admin/email-templates', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.listTemplates.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/admin/email-templates/:key - Get single email template
|
||||
fastify.get<{ Params: TemplateKeyParam }>('/admin/email-templates/:key', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.getTemplate.bind(controller)
|
||||
});
|
||||
|
||||
// PUT /api/admin/email-templates/:key - Update email template
|
||||
fastify.put<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: UpdateEmailTemplateRequest;
|
||||
}>('/admin/email-templates/:key', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.updateTemplate.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/admin/email-templates/:key/preview - Preview template with variables
|
||||
fastify.post<{
|
||||
Params: TemplateKeyParam;
|
||||
Body: PreviewTemplateRequest;
|
||||
}>('/admin/email-templates/:key/preview', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.previewTemplate.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/admin/email-templates/:key/test - Send test email to admin
|
||||
fastify.post<{ Params: TemplateKeyParam }>('/admin/email-templates/:key/test', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: controller.sendTestEmail.bind(controller)
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @ai-summary Validation schemas for notifications API
|
||||
* @ai-context Zod schemas for request validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Template key parameter validation
|
||||
export const TemplateKeyParamSchema = z.object({
|
||||
key: z.enum([
|
||||
'maintenance_due_soon',
|
||||
'maintenance_overdue',
|
||||
'document_expiring',
|
||||
'document_expired'
|
||||
])
|
||||
});
|
||||
export type TemplateKeyParam = z.infer<typeof TemplateKeyParamSchema>;
|
||||
|
||||
// Update email template request
|
||||
export const UpdateEmailTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateEmailTemplateRequest = z.infer<typeof UpdateEmailTemplateSchema>;
|
||||
|
||||
// Preview template request
|
||||
export const PreviewTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255),
|
||||
body: z.string().min(1),
|
||||
variables: z.record(z.string()),
|
||||
});
|
||||
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Pool } from 'pg';
|
||||
import pool from '../../../core/config/database';
|
||||
import type {
|
||||
EmailTemplate,
|
||||
NotificationLog,
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
TemplateKey
|
||||
} from '../domain/notifications.types';
|
||||
|
||||
export class NotificationsRepository {
|
||||
constructor(private readonly db: Pool = pool) {}
|
||||
|
||||
// ========================
|
||||
// Row Mappers
|
||||
// ========================
|
||||
|
||||
private mapEmailTemplate(row: any): EmailTemplate {
|
||||
return {
|
||||
id: row.id,
|
||||
templateKey: row.template_key,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
subject: row.subject,
|
||||
body: row.body,
|
||||
variables: row.variables,
|
||||
isActive: row.is_active,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
private mapDueMaintenanceItem(row: any): DueMaintenanceItem {
|
||||
return {
|
||||
id: row.id,
|
||||
vehicleId: row.vehicle_id,
|
||||
vehicleName: row.vehicle_name,
|
||||
category: row.category,
|
||||
subtypes: row.subtypes,
|
||||
nextDueDate: row.next_due_date,
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isDueSoon: row.is_due_soon,
|
||||
isOverdue: row.is_overdue,
|
||||
emailNotifications: row.email_notifications
|
||||
};
|
||||
}
|
||||
|
||||
private mapExpiringDocument(row: any): ExpiringDocument {
|
||||
return {
|
||||
id: row.id,
|
||||
vehicleId: row.vehicle_id,
|
||||
vehicleName: row.vehicle_name,
|
||||
documentType: row.document_type,
|
||||
title: row.title,
|
||||
expirationDate: row.expiration_date,
|
||||
isExpiringSoon: row.is_expiring_soon,
|
||||
isExpired: row.is_expired,
|
||||
emailNotifications: row.email_notifications
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Templates
|
||||
// ========================
|
||||
|
||||
async getEmailTemplates(): Promise<EmailTemplate[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM email_templates ORDER BY template_key`
|
||||
);
|
||||
return res.rows.map(row => this.mapEmailTemplate(row));
|
||||
}
|
||||
|
||||
async getEmailTemplateByKey(key: TemplateKey): Promise<EmailTemplate | null> {
|
||||
const res = await this.db.query(
|
||||
`SELECT * FROM email_templates WHERE template_key = $1`,
|
||||
[key]
|
||||
);
|
||||
return res.rows[0] ? this.mapEmailTemplate(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateEmailTemplate(
|
||||
key: TemplateKey,
|
||||
updates: { subject?: string; body?: string; isActive?: boolean }
|
||||
): Promise<EmailTemplate | null> {
|
||||
const fields: string[] = [];
|
||||
const params: (string | boolean)[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (updates.subject !== undefined) {
|
||||
fields.push(`subject = $${i++}`);
|
||||
params.push(updates.subject);
|
||||
}
|
||||
if (updates.body !== undefined) {
|
||||
fields.push(`body = $${i++}`);
|
||||
params.push(updates.body);
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
fields.push(`is_active = $${i++}`);
|
||||
params.push(updates.isActive);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.getEmailTemplateByKey(key);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
params.push(key);
|
||||
|
||||
const sql = `
|
||||
UPDATE email_templates
|
||||
SET ${fields.join(', ')}
|
||||
WHERE template_key = $${i}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const res = await this.db.query(sql, params);
|
||||
return res.rows[0] ? this.mapEmailTemplate(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Notification Logs
|
||||
// ========================
|
||||
|
||||
async insertNotificationLog(log: {
|
||||
user_id: string;
|
||||
notification_type: 'email' | 'toast';
|
||||
template_key: TemplateKey;
|
||||
recipient_email?: string;
|
||||
subject?: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
status?: 'pending' | 'sent' | 'failed';
|
||||
error_message?: string;
|
||||
}): Promise<NotificationLog> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO notification_logs (
|
||||
user_id, notification_type, template_key, recipient_email, subject,
|
||||
reference_type, reference_id, status, error_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
log.user_id,
|
||||
log.notification_type,
|
||||
log.template_key,
|
||||
log.recipient_email ?? null,
|
||||
log.subject ?? null,
|
||||
log.reference_type ?? null,
|
||||
log.reference_id ?? null,
|
||||
log.status ?? 'sent',
|
||||
log.error_message ?? null,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as NotificationLog;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Notification Summary
|
||||
// ========================
|
||||
|
||||
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
|
||||
// Get counts of due soon vs overdue maintenance items
|
||||
const maintenanceRes = await this.db.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE ms.next_due_date <= CURRENT_DATE) as overdue_count,
|
||||
COUNT(*) FILTER (WHERE ms.next_due_date > CURRENT_DATE AND ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days') as due_soon_count
|
||||
FROM maintenance_schedules ms
|
||||
WHERE ms.user_id = $1
|
||||
AND ms.is_active = true
|
||||
AND ms.next_due_date IS NOT NULL
|
||||
AND ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days'`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Get counts of expiring soon vs expired documents
|
||||
const documentRes = await this.db.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE d.expiration_date < CURRENT_DATE) as expired_count,
|
||||
COUNT(*) FILTER (WHERE d.expiration_date >= CURRENT_DATE AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days') as expiring_soon_count
|
||||
FROM documents d
|
||||
WHERE d.user_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND d.expiration_date IS NOT NULL
|
||||
AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days'`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return {
|
||||
maintenanceDueSoon: parseInt(maintenanceRes.rows[0]?.due_soon_count || '0', 10),
|
||||
maintenanceOverdue: parseInt(maintenanceRes.rows[0]?.overdue_count || '0', 10),
|
||||
documentsExpiringSoon: parseInt(documentRes.rows[0]?.expiring_soon_count || '0', 10),
|
||||
documentsExpired: parseInt(documentRes.rows[0]?.expired_count || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Due Maintenance Items
|
||||
// ========================
|
||||
|
||||
async getDueMaintenanceItems(userId: string): Promise<DueMaintenanceItem[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT
|
||||
ms.id,
|
||||
ms.vehicle_id,
|
||||
v.name as vehicle_name,
|
||||
ms.category,
|
||||
ms.subtypes,
|
||||
ms.next_due_date,
|
||||
ms.next_due_mileage,
|
||||
ms.email_notifications,
|
||||
CASE
|
||||
WHEN ms.next_due_date <= CURRENT_DATE THEN true
|
||||
WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage THEN true
|
||||
ELSE false
|
||||
END as is_overdue,
|
||||
CASE
|
||||
WHEN ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days' AND ms.next_due_date > CURRENT_DATE THEN true
|
||||
WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage - 500 AND v.odometer < ms.next_due_mileage THEN true
|
||||
ELSE false
|
||||
END as is_due_soon
|
||||
FROM maintenance_schedules ms
|
||||
JOIN vehicles v ON ms.vehicle_id = v.id
|
||||
WHERE ms.user_id = $1
|
||||
AND ms.is_active = true
|
||||
AND (
|
||||
ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days'
|
||||
OR (ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage - 500)
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN ms.next_due_date <= CURRENT_DATE THEN 0 ELSE 1 END,
|
||||
ms.next_due_date ASC NULLS LAST`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(row => this.mapDueMaintenanceItem(row));
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Expiring Documents
|
||||
// ========================
|
||||
|
||||
async getExpiringDocuments(userId: string): Promise<ExpiringDocument[]> {
|
||||
const res = await this.db.query(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.vehicle_id,
|
||||
v.name as vehicle_name,
|
||||
d.document_type,
|
||||
d.title,
|
||||
d.expiration_date,
|
||||
d.email_notifications,
|
||||
CASE
|
||||
WHEN d.expiration_date < CURRENT_DATE THEN true
|
||||
ELSE false
|
||||
END as is_expired,
|
||||
CASE
|
||||
WHEN d.expiration_date >= CURRENT_DATE AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days' THEN true
|
||||
ELSE false
|
||||
END as is_expiring_soon
|
||||
FROM documents d
|
||||
JOIN vehicles v ON d.vehicle_id = v.id
|
||||
WHERE d.user_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
AND d.expiration_date IS NOT NULL
|
||||
AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days'
|
||||
ORDER BY
|
||||
CASE WHEN d.expiration_date < CURRENT_DATE THEN 0 ELSE 1 END,
|
||||
d.expiration_date ASC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(row => this.mapExpiringDocument(row));
|
||||
}
|
||||
}
|
||||
54
backend/src/features/notifications/domain/email.service.ts
Normal file
54
backend/src/features/notifications/domain/email.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @ai-summary Email service using Resend
|
||||
* @ai-context Sends transactional emails with error handling
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
|
||||
export class EmailService {
|
||||
private resend: Resend;
|
||||
private fromEmail: string;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env['RESEND_API_KEY'];
|
||||
if (!apiKey) {
|
||||
throw new Error('RESEND_API_KEY is not configured');
|
||||
}
|
||||
|
||||
this.resend = new Resend(apiKey);
|
||||
this.fromEmail = process.env['FROM_EMAIL'] || 'noreply@motovaultpro.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Resend
|
||||
* @param to Recipient email address
|
||||
* @param subject Email subject line
|
||||
* @param html Email body (HTML format)
|
||||
* @returns Promise that resolves when email is sent
|
||||
*/
|
||||
async send(to: string, subject: string, html: string): Promise<void> {
|
||||
try {
|
||||
await this.resend.emails.send({
|
||||
from: this.fromEmail,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to send email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email with plain text body (converted to HTML)
|
||||
* @param to Recipient email address
|
||||
* @param subject Email subject line
|
||||
* @param text Email body (plain text)
|
||||
*/
|
||||
async sendText(to: string, subject: string, text: string): Promise<void> {
|
||||
// Convert plain text to HTML with proper line breaks
|
||||
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
|
||||
await this.send(to, subject, html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @ai-summary Notifications service with core business logic
|
||||
* @ai-context Manages notification summary, due items, and email sending
|
||||
*/
|
||||
|
||||
import { NotificationsRepository } from '../data/notifications.repository';
|
||||
import { TemplateService } from './template.service';
|
||||
import { EmailService } from './email.service';
|
||||
import type {
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
EmailTemplate,
|
||||
TemplateKey
|
||||
} from './notifications.types';
|
||||
|
||||
export class NotificationsService {
|
||||
private repository: NotificationsRepository;
|
||||
private templateService: TemplateService;
|
||||
private emailService: EmailService;
|
||||
|
||||
constructor(
|
||||
repository?: NotificationsRepository,
|
||||
templateService?: TemplateService,
|
||||
emailService?: EmailService
|
||||
) {
|
||||
this.repository = repository || new NotificationsRepository();
|
||||
this.templateService = templateService || new TemplateService();
|
||||
this.emailService = emailService || new EmailService();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Summary and Queries
|
||||
// ========================
|
||||
|
||||
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
|
||||
return this.repository.getNotificationSummary(userId);
|
||||
}
|
||||
|
||||
async getDueMaintenanceItems(userId: string): Promise<DueMaintenanceItem[]> {
|
||||
return this.repository.getDueMaintenanceItems(userId);
|
||||
}
|
||||
|
||||
async getExpiringDocuments(userId: string): Promise<ExpiringDocument[]> {
|
||||
return this.repository.getExpiringDocuments(userId);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Templates
|
||||
// ========================
|
||||
|
||||
async listTemplates(): Promise<EmailTemplate[]> {
|
||||
return this.repository.getEmailTemplates();
|
||||
}
|
||||
|
||||
async getTemplate(key: TemplateKey): Promise<EmailTemplate | null> {
|
||||
return this.repository.getEmailTemplateByKey(key);
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
key: TemplateKey,
|
||||
updates: { subject?: string; body?: string; isActive?: boolean }
|
||||
): Promise<EmailTemplate | null> {
|
||||
return this.repository.updateEmailTemplate(key, updates);
|
||||
}
|
||||
|
||||
async previewTemplate(
|
||||
subject: string,
|
||||
body: string,
|
||||
variables: Record<string, string | number | boolean | null | undefined>
|
||||
): Promise<{ subject: string; body: string }> {
|
||||
return {
|
||||
subject: this.templateService.render(subject, variables),
|
||||
body: this.templateService.render(body, variables),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Sending
|
||||
// ========================
|
||||
|
||||
async sendMaintenanceNotification(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
item: DueMaintenanceItem
|
||||
): Promise<void> {
|
||||
const templateKey: TemplateKey = item.isOverdue
|
||||
? 'maintenance_overdue'
|
||||
: 'maintenance_due_soon';
|
||||
|
||||
const template = await this.repository.getEmailTemplateByKey(templateKey);
|
||||
if (!template || !template.isActive) {
|
||||
throw new Error(`Template ${templateKey} not found or inactive`);
|
||||
}
|
||||
|
||||
const variables = {
|
||||
userName,
|
||||
vehicleName: item.vehicleName,
|
||||
category: item.category,
|
||||
subtypes: item.subtypes.join(', '),
|
||||
dueDate: item.nextDueDate || 'N/A',
|
||||
dueMileage: item.nextDueMileage?.toString() || 'N/A',
|
||||
};
|
||||
|
||||
const subject = this.templateService.render(template.subject, variables);
|
||||
const body = this.templateService.render(template.body, variables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(userEmail, subject, body);
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'maintenance_schedule',
|
||||
reference_id: item.id,
|
||||
status: 'sent',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'maintenance_schedule',
|
||||
reference_id: item.id,
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendDocumentNotification(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
document: ExpiringDocument
|
||||
): Promise<void> {
|
||||
const templateKey: TemplateKey = document.isExpired
|
||||
? 'document_expired'
|
||||
: 'document_expiring';
|
||||
|
||||
const template = await this.repository.getEmailTemplateByKey(templateKey);
|
||||
if (!template || !template.isActive) {
|
||||
throw new Error(`Template ${templateKey} not found or inactive`);
|
||||
}
|
||||
|
||||
const variables = {
|
||||
userName,
|
||||
vehicleName: document.vehicleName,
|
||||
documentType: document.documentType,
|
||||
documentTitle: document.title,
|
||||
expirationDate: document.expirationDate || 'N/A',
|
||||
};
|
||||
|
||||
const subject = this.templateService.render(template.subject, variables);
|
||||
const body = this.templateService.render(template.body, variables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(userEmail, subject, body);
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'document',
|
||||
reference_id: document.id,
|
||||
status: 'sent',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
await this.repository.insertNotificationLog({
|
||||
user_id: userId,
|
||||
notification_type: 'email',
|
||||
template_key: templateKey,
|
||||
recipient_email: userEmail,
|
||||
subject,
|
||||
reference_type: 'document',
|
||||
reference_id: document.id,
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test email for a template to verify email configuration
|
||||
* @param key Template key to test
|
||||
* @param recipientEmail Email address to send test to
|
||||
* @param recipientName Name to use in template
|
||||
* @returns Rendered subject and body that was sent
|
||||
*/
|
||||
async sendTestEmail(
|
||||
key: TemplateKey,
|
||||
recipientEmail: string,
|
||||
recipientName: string
|
||||
): Promise<{ subject: string; body: string; success: boolean; error?: string }> {
|
||||
const template = await this.repository.getEmailTemplateByKey(key);
|
||||
if (!template) {
|
||||
return {
|
||||
subject: '',
|
||||
body: '',
|
||||
success: false,
|
||||
error: `Template '${key}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Sample variables based on template type
|
||||
const sampleVariables = this.getSampleVariables(key, recipientName);
|
||||
|
||||
const subject = this.templateService.render(template.subject, sampleVariables);
|
||||
const body = this.templateService.render(template.body, sampleVariables);
|
||||
|
||||
try {
|
||||
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
|
||||
|
||||
return {
|
||||
subject,
|
||||
body,
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
subject,
|
||||
body,
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample variables for a template based on its type
|
||||
*/
|
||||
private getSampleVariables(key: TemplateKey, userName: string): Record<string, string> {
|
||||
const baseVariables = { userName };
|
||||
|
||||
switch (key) {
|
||||
case 'maintenance_due_soon':
|
||||
case 'maintenance_overdue':
|
||||
return {
|
||||
...baseVariables,
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
category: 'Routine Maintenance',
|
||||
subtypes: 'Oil Change, Air Filter',
|
||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString(),
|
||||
dueMileage: '50,000',
|
||||
};
|
||||
case 'document_expiring':
|
||||
case 'document_expired':
|
||||
return {
|
||||
...baseVariables,
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
documentType: 'Insurance',
|
||||
documentTitle: 'State Farm Auto Policy',
|
||||
expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString(),
|
||||
};
|
||||
default:
|
||||
return baseVariables;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending notifications (called by scheduled job)
|
||||
* This would typically be called by a cron job or scheduler
|
||||
*/
|
||||
async processNotifications(): Promise<void> {
|
||||
// This is a placeholder for batch notification processing
|
||||
// In a production system, this would:
|
||||
// 1. Query for users with email_notifications enabled
|
||||
// 2. Check which items need notifications
|
||||
// 3. Send batch emails
|
||||
// 4. Track sent notifications to avoid duplicates
|
||||
throw new Error('Batch notification processing not yet implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for notifications feature
|
||||
* @ai-context Email and toast notifications for maintenance and documents
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Template key union type
|
||||
export type TemplateKey =
|
||||
| 'maintenance_due_soon'
|
||||
| 'maintenance_overdue'
|
||||
| 'document_expiring'
|
||||
| 'document_expired';
|
||||
|
||||
// Email template API response type (camelCase for frontend)
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
templateKey: TemplateKey;
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
variables: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Notification log database type
|
||||
export interface NotificationLog {
|
||||
id: string;
|
||||
user_id: string;
|
||||
notification_type: 'email' | 'toast';
|
||||
template_key: TemplateKey;
|
||||
recipient_email?: string;
|
||||
subject?: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
status: 'pending' | 'sent' | 'failed';
|
||||
error_message?: string;
|
||||
sent_at: string;
|
||||
}
|
||||
|
||||
// Summary for login toast (camelCase for frontend compatibility)
|
||||
export interface NotificationSummary {
|
||||
maintenanceDueSoon: number;
|
||||
maintenanceOverdue: number;
|
||||
documentsExpiringSoon: number;
|
||||
documentsExpired: number;
|
||||
}
|
||||
|
||||
// Due maintenance item (camelCase for frontend)
|
||||
export interface DueMaintenanceItem {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
category: string;
|
||||
subtypes: string[];
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isDueSoon: boolean;
|
||||
isOverdue: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
// Expiring document (camelCase for frontend)
|
||||
export interface ExpiringDocument {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
expirationDate?: string;
|
||||
isExpiringSoon: boolean;
|
||||
isExpired: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
// Zod schemas for validation
|
||||
export const TemplateKeySchema = z.enum([
|
||||
'maintenance_due_soon',
|
||||
'maintenance_overdue',
|
||||
'document_expiring',
|
||||
'document_expired'
|
||||
]);
|
||||
|
||||
export const UpdateEmailTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateEmailTemplateRequest = z.infer<typeof UpdateEmailTemplateSchema>;
|
||||
|
||||
export const PreviewTemplateSchema = z.object({
|
||||
subject: z.string().min(1).max(255),
|
||||
body: z.string().min(1),
|
||||
variables: z.record(z.string()),
|
||||
});
|
||||
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @ai-summary Simple template variable substitution service
|
||||
* @ai-context Replaces {{variableName}} with values
|
||||
*/
|
||||
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Render a template string by replacing {{variableName}} with values
|
||||
* @param template Template string with {{variable}} placeholders
|
||||
* @param variables Object mapping variable names to values
|
||||
* @returns Rendered string with variables replaced
|
||||
*/
|
||||
render(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
|
||||
let result = template;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
const replacement = value !== null && value !== undefined ? String(value) : '';
|
||||
result = result.split(placeholder).join(replacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variable names from a template string
|
||||
* @param template Template string with {{variable}} placeholders
|
||||
* @returns Array of variable names found in template
|
||||
*/
|
||||
extractVariables(template: string): string[] {
|
||||
const regex = /\{\{(\w+)\}\}/g;
|
||||
const variables: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(template)) !== null) {
|
||||
if (!variables.includes(match[1])) {
|
||||
variables.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
}
|
||||
6
backend/src/features/notifications/index.ts
Normal file
6
backend/src/features/notifications/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Notifications feature module export
|
||||
* @ai-context Exports routes for registration in app.ts
|
||||
*/
|
||||
|
||||
export { notificationsRoutes } from './api/notifications.routes';
|
||||
@@ -0,0 +1,93 @@
|
||||
-- email_templates: Admin-editable predefined templates
|
||||
CREATE TABLE email_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
template_key VARCHAR(50) NOT NULL UNIQUE CHECK (template_key IN (
|
||||
'maintenance_due_soon', 'maintenance_overdue',
|
||||
'document_expiring', 'document_expired'
|
||||
)),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
variables JSONB DEFAULT '[]'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- notification_logs: Track sent notifications
|
||||
CREATE TABLE notification_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
notification_type VARCHAR(20) NOT NULL CHECK (notification_type IN ('email', 'toast')),
|
||||
template_key VARCHAR(50) NOT NULL,
|
||||
recipient_email VARCHAR(255),
|
||||
subject VARCHAR(255),
|
||||
reference_type VARCHAR(50), -- 'maintenance_schedule' or 'document'
|
||||
reference_id UUID,
|
||||
status VARCHAR(20) DEFAULT 'sent' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
error_message TEXT,
|
||||
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_notification_logs_user_id ON notification_logs(user_id);
|
||||
CREATE INDEX idx_notification_logs_reference ON notification_logs(reference_type, reference_id);
|
||||
CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at DESC);
|
||||
|
||||
-- Seed 4 default templates
|
||||
INSERT INTO email_templates (template_key, name, description, subject, body, variables) VALUES
|
||||
('maintenance_due_soon', 'Maintenance Due Soon', 'Sent when maintenance is due within 30 days or 500 miles',
|
||||
'MotoVaultPro: Maintenance Due Soon for {{vehicleName}}',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{category}} maintenance for {{vehicleName}} is due soon.
|
||||
|
||||
Due Date: {{dueDate}}
|
||||
Due Mileage: {{dueMileage}} miles
|
||||
Items: {{subtypes}}
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "category", "subtypes", "dueDate", "dueMileage"]'),
|
||||
('maintenance_overdue', 'Maintenance Overdue', 'Sent when maintenance is past due',
|
||||
'MotoVaultPro: OVERDUE Maintenance for {{vehicleName}}',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{category}} maintenance for {{vehicleName}} is OVERDUE.
|
||||
|
||||
Was Due: {{dueDate}}
|
||||
Was Due At: {{dueMileage}} miles
|
||||
Items: {{subtypes}}
|
||||
|
||||
Please schedule service as soon as possible.
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "category", "subtypes", "dueDate", "dueMileage"]'),
|
||||
('document_expiring', 'Document Expiring Soon', 'Sent when document expires within 30 days',
|
||||
'MotoVaultPro: {{documentTitle}} Expiring Soon',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{documentType}} document "{{documentTitle}}" for {{vehicleName}} is expiring soon.
|
||||
|
||||
Expiration Date: {{expirationDate}}
|
||||
|
||||
Please renew before expiration.
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "documentType", "documentTitle", "expirationDate"]'),
|
||||
('document_expired', 'Document Expired', 'Sent when document has expired',
|
||||
'MotoVaultPro: {{documentTitle}} Has EXPIRED',
|
||||
'Hi {{userName}},
|
||||
|
||||
Your {{documentType}} document "{{documentTitle}}" for {{vehicleName}} has EXPIRED.
|
||||
|
||||
Expired On: {{expirationDate}}
|
||||
|
||||
Please renew immediately.
|
||||
|
||||
Best regards,
|
||||
MotoVaultPro',
|
||||
'["userName", "vehicleName", "documentType", "documentTitle", "expirationDate"]');
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE maintenance_schedules ADD COLUMN email_notifications BOOLEAN DEFAULT false;
|
||||
ALTER TABLE documents ADD COLUMN email_notifications BOOLEAN DEFAULT false;
|
||||
|
||||
CREATE INDEX idx_maintenance_schedules_email_notifications
|
||||
ON maintenance_schedules(email_notifications) WHERE email_notifications = true AND is_active = true;
|
||||
CREATE INDEX idx_documents_email_notifications
|
||||
ON documents(email_notifications) WHERE email_notifications = true AND deleted_at IS NULL;
|
||||
90
backend/src/features/user-profile/README.md
Normal file
90
backend/src/features/user-profile/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# User Profile Feature Capsule
|
||||
|
||||
## Quick Summary
|
||||
|
||||
User profile management system that stores user information (email, display name, notification email) with defaults from Auth0 but allows user edits. Profile is automatically created on first login and persists user preferences.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### User Endpoints
|
||||
- `GET /api/user/profile` - Get current user's profile (creates if not exists)
|
||||
- `PUT /api/user/profile` - Update user profile (display name, notification email)
|
||||
|
||||
## Structure
|
||||
|
||||
- **api/** - HTTP endpoints, routes, validators
|
||||
- **domain/** - Business logic, services, types
|
||||
- **data/** - Repository, database queries
|
||||
- **migrations/** - Feature-specific schema
|
||||
|
||||
## Profile Data Model
|
||||
|
||||
### Fields
|
||||
- `id` - UUID primary key
|
||||
- `auth0_sub` - Auth0 user ID (unique, indexed)
|
||||
- `email` - User email (from Auth0)
|
||||
- `display_name` - Optional user-defined display name
|
||||
- `notification_email` - Optional alternate email for notifications
|
||||
- `created_at` - Profile creation timestamp
|
||||
- `updated_at` - Last update timestamp (auto-updated)
|
||||
|
||||
### Default Values
|
||||
On first login, profile is created with:
|
||||
- `email` - From Auth0 user token
|
||||
- `display_name` - From Auth0 user name (optional)
|
||||
- Other fields - null/empty
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `core/auth` - Authentication plugin (fastify.authenticate)
|
||||
- `core/logging` - Structured logging
|
||||
- `core/config` - Database pool
|
||||
|
||||
### External
|
||||
- None (Auth0 integration via core/auth)
|
||||
|
||||
### Database
|
||||
- Tables: `user_profiles`
|
||||
- Indexes: `idx_user_profiles_auth0_sub` on `auth0_sub`
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Profile Creation
|
||||
- Profile created automatically on first API call after login
|
||||
- Uses Auth0 token data (sub, email, name) as defaults
|
||||
- `getOrCreate` pattern ensures idempotent profile access
|
||||
|
||||
### Profile Updates
|
||||
- Users can update `display_name` and `notification_email`
|
||||
- Email field is read-only (managed by Auth0)
|
||||
- At least one field required for update request
|
||||
- Validation enforced via Zod schemas
|
||||
|
||||
### Data Privacy
|
||||
- All queries scoped to authenticated user (auth0_sub)
|
||||
- No cross-user profile access
|
||||
- Profile data isolated per user
|
||||
|
||||
## Security Requirements
|
||||
|
||||
1. All endpoints require JWT authentication
|
||||
2. Users can only access their own profile
|
||||
3. Prepared statements prevent SQL injection
|
||||
4. Input validation via Zod schemas
|
||||
5. Email format validation for notification_email
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run feature tests (when implemented)
|
||||
npm test -- features/user-profile
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Avatar/profile photo upload
|
||||
- Additional user preferences (timezone, language, etc.)
|
||||
- Profile completion progress indicator
|
||||
- User account deletion/deactivation
|
||||
- Profile history/audit trail
|
||||
124
backend/src/features/user-profile/api/user-profile.controller.ts
Normal file
124
backend/src/features/user-profile/api/user-profile.controller.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for user profile API
|
||||
* @ai-context HTTP request/response handling with user authentication
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserProfileService } from '../domain/user-profile.service';
|
||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { UpdateProfileInput, updateProfileSchema } from './user-profile.validation';
|
||||
|
||||
export class UserProfileController {
|
||||
private userProfileService: UserProfileService;
|
||||
|
||||
constructor() {
|
||||
const repository = new UserProfileRepository(pool);
|
||||
this.userProfileService = new UserProfileService(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/profile - Get current user's profile
|
||||
*/
|
||||
async getProfile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user data from Auth0 token
|
||||
const auth0User = {
|
||||
sub: auth0Sub,
|
||||
email: (request as any).user?.email || request.userContext?.email || '',
|
||||
name: (request as any).user?.name,
|
||||
};
|
||||
|
||||
// Get or create profile
|
||||
const profile = await this.userProfileService.getOrCreateProfile(
|
||||
auth0Sub,
|
||||
auth0User
|
||||
);
|
||||
|
||||
return reply.code(200).send(profile);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting user profile', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve user profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/user/profile - Update user profile
|
||||
*/
|
||||
async updateProfile(
|
||||
request: FastifyRequest<{ Body: UpdateProfileInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = updateProfileSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const updates = validation.data;
|
||||
|
||||
// Update profile
|
||||
const profile = await this.userProfileService.updateProfile(
|
||||
auth0Sub,
|
||||
updates
|
||||
);
|
||||
|
||||
return reply.code(200).send(profile);
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating user profile', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('At least one field')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update user profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/src/features/user-profile/api/user-profile.routes.ts
Normal file
24
backend/src/features/user-profile/api/user-profile.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary User profile feature routes
|
||||
* @ai-context Registers user profile API endpoints with authentication
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { UserProfileController } from './user-profile.controller';
|
||||
import { UpdateProfileInput } from './user-profile.validation';
|
||||
|
||||
export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const userProfileController = new UserProfileController();
|
||||
|
||||
// GET /api/user/profile - Get current user's profile
|
||||
fastify.get('/user/profile', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.getProfile.bind(userProfileController),
|
||||
});
|
||||
|
||||
// PUT /api/user/profile - Update user profile
|
||||
fastify.put<{ Body: UpdateProfileInput }>('/user/profile', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.updateProfile.bind(userProfileController),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for user profile API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
displayName: z.string().max(100, 'Display name must be 100 characters or less').optional(),
|
||||
notificationEmail: z.string().email('Invalid email format').optional(),
|
||||
}).refine(
|
||||
(data) => data.displayName !== undefined || data.notificationEmail !== undefined,
|
||||
{
|
||||
message: 'At least one field (displayName or notificationEmail) must be provided',
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* @ai-summary User profile data access layer
|
||||
* @ai-context Provides parameterized SQL queries for user profile operations
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
UserProfile,
|
||||
UserWithAdminStatus,
|
||||
ListUsersQuery,
|
||||
ListUsersResponse,
|
||||
SubscriptionTier,
|
||||
} from '../domain/user-profile.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
// Base columns for user profile queries
|
||||
const USER_PROFILE_COLUMNS = `
|
||||
id, auth0_sub, email, display_name, notification_email,
|
||||
subscription_tier, deactivated_at, deactivated_by,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
export class UserProfileRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async getByAuth0Sub(auth0Sub: string): Promise<UserProfile | null> {
|
||||
const query = `
|
||||
SELECT ${USER_PROFILE_COLUMNS}
|
||||
FROM user_profiles
|
||||
WHERE auth0_sub = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user profile by auth0_sub', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
auth0Sub: string,
|
||||
email: string,
|
||||
displayName?: string
|
||||
): Promise<UserProfile> {
|
||||
const query = `
|
||||
INSERT INTO user_profiles (auth0_sub, email, display_name, subscription_tier)
|
||||
VALUES ($1, $2, $3, 'free')
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [
|
||||
auth0Sub,
|
||||
email,
|
||||
displayName || null,
|
||||
]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Failed to create user profile');
|
||||
}
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating user profile', { error, auth0Sub, email });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
auth0Sub: string,
|
||||
updates: { displayName?: string; notificationEmail?: string }
|
||||
): Promise<UserProfile> {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
setClauses.push(`display_name = $${paramIndex++}`);
|
||||
values.push(updates.displayName);
|
||||
}
|
||||
|
||||
if (updates.notificationEmail !== undefined) {
|
||||
setClauses.push(`notification_email = $${paramIndex++}`);
|
||||
values.push(updates.notificationEmail);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(auth0Sub);
|
||||
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE auth0_sub = $${paramIndex}
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile', { error, auth0Sub, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreate(
|
||||
auth0Sub: string,
|
||||
defaults: { email: string; displayName?: string }
|
||||
): Promise<UserProfile> {
|
||||
// Try to find existing profile
|
||||
const existing = await this.getByAuth0Sub(auth0Sub);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new profile if not found
|
||||
return await this.create(auth0Sub, defaults.email, defaults.displayName);
|
||||
}
|
||||
|
||||
private mapRowToUserProfile(row: any): UserProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
auth0Sub: row.auth0_sub,
|
||||
email: row.email,
|
||||
displayName: row.display_name,
|
||||
notificationEmail: row.notification_email,
|
||||
subscriptionTier: row.subscription_tier || 'free',
|
||||
deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null,
|
||||
deactivatedBy: row.deactivated_by || null,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus {
|
||||
return {
|
||||
...this.mapRowToUserProfile(row),
|
||||
isAdmin: !!row.admin_auth0_sub,
|
||||
adminRole: row.admin_role || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination, search, and filters
|
||||
* Includes admin status by joining with admin_users table
|
||||
*/
|
||||
async listAllUsers(query: ListUsersQuery): Promise<ListUsersResponse> {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const sortBy = query.sortBy || 'createdAt';
|
||||
const sortOrder = query.sortOrder || 'desc';
|
||||
|
||||
// Map sortBy to column names
|
||||
const sortColumnMap: Record<string, string> = {
|
||||
email: 'up.email',
|
||||
createdAt: 'up.created_at',
|
||||
displayName: 'up.display_name',
|
||||
subscriptionTier: 'up.subscription_tier',
|
||||
};
|
||||
const sortColumn = sortColumnMap[sortBy] || 'up.created_at';
|
||||
|
||||
const whereClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Search filter (email or display_name)
|
||||
if (query.search) {
|
||||
whereClauses.push(`(up.email ILIKE $${paramIndex} OR up.display_name ILIKE $${paramIndex})`);
|
||||
values.push(`%${query.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Tier filter
|
||||
if (query.tier) {
|
||||
whereClauses.push(`up.subscription_tier = $${paramIndex}`);
|
||||
values.push(query.tier);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (query.status === 'active') {
|
||||
whereClauses.push('up.deactivated_at IS NULL');
|
||||
} else if (query.status === 'deactivated') {
|
||||
whereClauses.push('up.deactivated_at IS NOT NULL');
|
||||
}
|
||||
// 'all' means no filter
|
||||
|
||||
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
// Count query
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM user_profiles up
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
// Data query with admin status join
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||
up.subscription_tier, up.deactivated_at, up.deactivated_by,
|
||||
up.created_at, up.updated_at,
|
||||
au.auth0_sub as admin_auth0_sub,
|
||||
au.role as admin_role
|
||||
FROM user_profiles up
|
||||
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
try {
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery, values),
|
||||
this.pool.query(dataQuery, [...values, pageSize, offset]),
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
const users = dataResult.rows.map((row) => this.mapRowToUserWithAdminStatus(row));
|
||||
|
||||
return { users, total, page, pageSize };
|
||||
} catch (error) {
|
||||
logger.error('Error listing users', { error, query });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single user with admin status
|
||||
*/
|
||||
async getUserWithAdminStatus(auth0Sub: string): Promise<UserWithAdminStatus | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||
up.subscription_tier, up.deactivated_at, up.deactivated_by,
|
||||
up.created_at, up.updated_at,
|
||||
au.auth0_sub as admin_auth0_sub,
|
||||
au.role as admin_role
|
||||
FROM user_profiles up
|
||||
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
|
||||
WHERE up.auth0_sub = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserWithAdminStatus(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user with admin status', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription tier
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
auth0Sub: string,
|
||||
tier: SubscriptionTier
|
||||
): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET subscription_tier = $1
|
||||
WHERE auth0_sub = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [tier, auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription tier', { error, auth0Sub, tier });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user (soft delete)
|
||||
*/
|
||||
async deactivateUser(auth0Sub: string, deactivatedBy: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET deactivated_at = NOW(), deactivated_by = $1
|
||||
WHERE auth0_sub = $2 AND deactivated_at IS NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or already deactivated');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating user', { error, auth0Sub, deactivatedBy });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate user
|
||||
*/
|
||||
async reactivateUser(auth0Sub: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET deactivated_at = NULL, deactivated_by = NULL
|
||||
WHERE auth0_sub = $1 AND deactivated_at IS NOT NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or not deactivated');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating user', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin update of user profile (can update email and displayName)
|
||||
*/
|
||||
async adminUpdateProfile(
|
||||
auth0Sub: string,
|
||||
updates: { email?: string; displayName?: string }
|
||||
): Promise<UserProfile> {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.email !== undefined) {
|
||||
setClauses.push(`email = $${paramIndex++}`);
|
||||
values.push(updates.email);
|
||||
}
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
setClauses.push(`display_name = $${paramIndex++}`);
|
||||
values.push(updates.displayName);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(auth0Sub);
|
||||
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET ${setClauses.join(', ')}, updated_at = NOW()
|
||||
WHERE auth0_sub = $${paramIndex}
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error admin updating user profile', { error, auth0Sub, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
323
backend/src/features/user-profile/domain/user-profile.service.ts
Normal file
323
backend/src/features/user-profile/domain/user-profile.service.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @ai-summary User profile business logic
|
||||
* @ai-context Handles user profile operations with Auth0 integration
|
||||
*/
|
||||
|
||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||
import {
|
||||
UserProfile,
|
||||
UserWithAdminStatus,
|
||||
UpdateProfileRequest,
|
||||
Auth0UserData,
|
||||
ListUsersQuery,
|
||||
ListUsersResponse,
|
||||
SubscriptionTier,
|
||||
} from './user-profile.types';
|
||||
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class UserProfileService {
|
||||
private adminRepository: AdminRepository | null = null;
|
||||
|
||||
constructor(private repository: UserProfileRepository) {}
|
||||
|
||||
/**
|
||||
* Set admin repository for audit logging (optional dependency injection)
|
||||
*/
|
||||
setAdminRepository(adminRepository: AdminRepository): void {
|
||||
this.adminRepository = adminRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create user profile from Auth0 user data
|
||||
* This method is called when a user logs in to ensure their profile exists
|
||||
*/
|
||||
async getOrCreateProfile(
|
||||
auth0Sub: string,
|
||||
auth0User: Auth0UserData
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
const profile = await this.repository.getOrCreate(auth0Sub, {
|
||||
email: auth0User.email,
|
||||
displayName: auth0User.name,
|
||||
});
|
||||
|
||||
logger.info('User profile retrieved or created', {
|
||||
auth0Sub,
|
||||
profileId: profile.id,
|
||||
});
|
||||
|
||||
return profile;
|
||||
} catch (error) {
|
||||
logger.error('Error getting or creating user profile', {
|
||||
error,
|
||||
auth0Sub,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile by Auth0 sub
|
||||
*/
|
||||
async getProfile(auth0Sub: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
return await this.repository.getByAuth0Sub(auth0Sub);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user profile', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
async updateProfile(
|
||||
auth0Sub: string,
|
||||
updates: UpdateProfileRequest
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Validate that at least one field is being updated
|
||||
if (!updates.displayName && !updates.notificationEmail) {
|
||||
throw new Error('At least one field must be provided for update');
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
const profile = await this.repository.update(auth0Sub, updates);
|
||||
|
||||
logger.info('User profile updated', {
|
||||
auth0Sub,
|
||||
profileId: profile.id,
|
||||
updatedFields: Object.keys(updates),
|
||||
});
|
||||
|
||||
return profile;
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile', { error, auth0Sub, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin-focused methods (for user management)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* List all users with pagination and filters (admin-only)
|
||||
*/
|
||||
async listAllUsers(query: ListUsersQuery): Promise<ListUsersResponse> {
|
||||
try {
|
||||
return await this.repository.listAllUsers(query);
|
||||
} catch (error) {
|
||||
logger.error('Error listing all users', { error, query });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details with admin status (admin-only)
|
||||
*/
|
||||
async getUserDetails(auth0Sub: string): Promise<UserWithAdminStatus | null> {
|
||||
try {
|
||||
return await this.repository.getUserWithAdminStatus(auth0Sub);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user details', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription tier (admin-only)
|
||||
* Logs the change to admin audit logs
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
auth0Sub: string,
|
||||
tier: SubscriptionTier,
|
||||
actorAuth0Sub: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Get current user to log the change
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const previousTier = currentUser.subscriptionTier;
|
||||
|
||||
// Perform the update
|
||||
const updatedProfile = await this.repository.updateSubscriptionTier(auth0Sub, tier);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'UPDATE_TIER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{ previousTier, newTier: tier }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User subscription tier updated', {
|
||||
auth0Sub,
|
||||
previousTier,
|
||||
newTier: tier,
|
||||
actorAuth0Sub,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription tier', { error, auth0Sub, tier, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user account (admin-only soft delete)
|
||||
* Prevents self-deactivation
|
||||
*/
|
||||
async deactivateUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string,
|
||||
reason?: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Prevent self-deactivation
|
||||
if (auth0Sub === actorAuth0Sub) {
|
||||
throw new Error('Cannot deactivate your own account');
|
||||
}
|
||||
|
||||
// Verify user exists and is not already deactivated
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (currentUser.deactivatedAt) {
|
||||
throw new Error('User is already deactivated');
|
||||
}
|
||||
|
||||
// Perform the deactivation
|
||||
const deactivatedProfile = await this.repository.deactivateUser(auth0Sub, actorAuth0Sub);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'DEACTIVATE_USER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
deactivatedProfile.id,
|
||||
{ reason: reason || 'No reason provided' }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User deactivated', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
reason,
|
||||
});
|
||||
|
||||
return deactivatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating user', { error, auth0Sub, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a deactivated user account (admin-only)
|
||||
*/
|
||||
async reactivateUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Verify user exists and is deactivated
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (!currentUser.deactivatedAt) {
|
||||
throw new Error('User is not deactivated');
|
||||
}
|
||||
|
||||
// Perform the reactivation
|
||||
const reactivatedProfile = await this.repository.reactivateUser(auth0Sub);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'REACTIVATE_USER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
reactivatedProfile.id,
|
||||
{ previouslyDeactivatedBy: currentUser.deactivatedBy }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User reactivated', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
});
|
||||
|
||||
return reactivatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating user', { error, auth0Sub, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin update of user profile (email, displayName)
|
||||
* Logs the change to admin audit logs
|
||||
*/
|
||||
async adminUpdateProfile(
|
||||
auth0Sub: string,
|
||||
updates: { email?: string; displayName?: string },
|
||||
actorAuth0Sub: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Get current user to log the change
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const previousValues = {
|
||||
email: currentUser.email,
|
||||
displayName: currentUser.displayName,
|
||||
};
|
||||
|
||||
// Perform the update
|
||||
const updatedProfile = await this.repository.adminUpdateProfile(auth0Sub, updates);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'UPDATE_PROFILE',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{
|
||||
previousValues,
|
||||
newValues: updates,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('User profile updated by admin', {
|
||||
auth0Sub,
|
||||
updatedFields: Object.keys(updates),
|
||||
actorAuth0Sub,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error admin updating user profile', { error, auth0Sub, updates, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @ai-summary User profile domain types
|
||||
* @ai-context Type definitions for user profile data models
|
||||
*/
|
||||
|
||||
// Subscription tier enum
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
deactivatedAt: Date | null;
|
||||
deactivatedBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// User with admin status for admin views (joined from admin_users table)
|
||||
export interface UserWithAdminStatus extends UserProfile {
|
||||
isAdmin: boolean;
|
||||
adminRole: 'admin' | 'super_admin' | null;
|
||||
}
|
||||
|
||||
// Pagination and filter query params for listing users
|
||||
export interface ListUsersQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
tier?: SubscriptionTier;
|
||||
status?: 'active' | 'deactivated' | 'all';
|
||||
sortBy?: 'email' | 'createdAt' | 'displayName' | 'subscriptionTier';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Paginated response for user listing
|
||||
export interface ListUsersResponse {
|
||||
users: UserWithAdminStatus[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Request to update subscription tier
|
||||
export interface UpdateTierRequest {
|
||||
subscriptionTier: SubscriptionTier;
|
||||
}
|
||||
|
||||
// Request to deactivate a user
|
||||
export interface DeactivateUserRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
}
|
||||
|
||||
export interface Auth0UserData {
|
||||
sub: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
10
backend/src/features/user-profile/index.ts
Normal file
10
backend/src/features/user-profile/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @ai-summary User profile feature exports
|
||||
* @ai-context Barrel export for user profile feature
|
||||
*/
|
||||
|
||||
export { userProfileRoutes } from './api/user-profile.routes';
|
||||
export { UserProfileController } from './api/user-profile.controller';
|
||||
export { UserProfileService } from './domain/user-profile.service';
|
||||
export { UserProfileRepository } from './data/user-profile.repository';
|
||||
export type { UserProfile, UpdateProfileRequest } from './domain/user-profile.types';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- User Profile Table
|
||||
-- Stores user profile information that defaults from Auth0 but allows user edits
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
auth0_sub VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
notification_email VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for efficient lookup by Auth0 user ID
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_auth0_sub ON user_profiles(auth0_sub);
|
||||
|
||||
-- Trigger to automatically update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_user_profiles_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER user_profiles_updated_at
|
||||
BEFORE UPDATE ON user_profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_user_profiles_updated_at();
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Add subscription tier and soft delete support to user_profiles
|
||||
-- Migration: 002_add_subscription_and_deactivation.sql
|
||||
|
||||
-- Create subscription tier ENUM type
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE subscription_tier AS ENUM ('free', 'pro', 'enterprise');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Add subscription_tier column (default to 'free' for all users)
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS subscription_tier subscription_tier NOT NULL DEFAULT 'free';
|
||||
|
||||
-- Add soft delete fields
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;
|
||||
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS deactivated_by VARCHAR(255) DEFAULT NULL;
|
||||
|
||||
-- Index for filtering by subscription tier
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_subscription_tier
|
||||
ON user_profiles(subscription_tier);
|
||||
|
||||
-- Index for filtering active vs deactivated users
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_deactivated_at
|
||||
ON user_profiles(deactivated_at);
|
||||
|
||||
-- Composite index for efficient admin queries (tier + active status)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_tier_status
|
||||
ON user_profiles(subscription_tier, deactivated_at);
|
||||
@@ -39,7 +39,7 @@ export function normalizeModelName(input?: string | null): string | undefined {
|
||||
|
||||
export function normalizeMakeName(input?: string | null): string | undefined {
|
||||
if (input == null) return input ?? undefined;
|
||||
let s = String(input).replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const s = String(input).replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (s.length === 0) return s;
|
||||
const title = s.toLowerCase().split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
// Special cases
|
||||
|
||||
Reference in New Issue
Block a user