Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

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

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

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