Bunch of stuff again

This commit is contained in:
2026-02-16 18:48:54 -07:00
parent 7895ce683e
commit a7978de5a0
135 changed files with 19366 additions and 1002 deletions

View File

@@ -4,8 +4,8 @@
FROM node:20-alpine AS base
WORKDIR /app
# Install ffmpeg for video metadata extraction
RUN apk add --no-cache ffmpeg
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Install dependencies
COPY package*.json ./
@@ -35,8 +35,8 @@ RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Install ffmpeg for video metadata extraction
RUN apk add --no-cache ffmpeg
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Copy built files and node_modules
COPY --from=build /app/dist ./dist

View File

@@ -24,12 +24,15 @@ enum UserStatus {
INACTIVE
SUSPENDED
EXPIRED
PENDING_VERIFICATION
PENDING_APPROVAL
}
enum UserCreatedVia {
ADMIN
PUBLIC_SHIFT_SIGNUP
STANDARD
SELF_REGISTRATION
}
model User {
@@ -39,6 +42,7 @@ model User {
name String?
phone String?
role UserRole @default(USER)
roles Json @default("[]") // Array of UserRole strings for multi-role support
status UserStatus @default(ACTIVE)
permissions Json? // Per-app granular permissions
createdVia UserCreatedVia @default(STANDARD)
@@ -51,6 +55,7 @@ model User {
refreshTokens RefreshToken[]
campaignsCreated Campaign[] @relation("CampaignCreator")
campaignsReviewed Campaign[] @relation("CampaignReviewer")
campaignEmails CampaignEmail[] @relation("CampaignEmailSender")
responses RepresentativeResponse[] @relation("ResponseSubmitter")
responseUpvotes ResponseUpvote[]
@@ -154,6 +159,13 @@ enum CampaignStatus {
ARCHIVED
}
enum CampaignModerationStatus {
PENDING_REVIEW
APPROVED
REJECTED
CHANGES_REQUESTED
}
enum GovernmentLevel {
FEDERAL
PROVINCIAL
@@ -192,6 +204,15 @@ model Campaign {
createdByUserEmail String?
createdByUserName String?
// User-generated campaign moderation
isUserGenerated Boolean @default(false)
moderationStatus CampaignModerationStatus?
reviewedByUserId String?
reviewedByUser User? @relation("CampaignReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull)
reviewedAt DateTime?
rejectionReason String? @db.Text
moderationNotes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -200,6 +221,8 @@ model Campaign {
customRecipients CustomRecipient[]
calls Call[]
@@index([moderationStatus])
@@index([isUserGenerated])
@@map("campaigns")
}
@@ -803,6 +826,11 @@ model SiteSettings {
emailTestMode Boolean @default(true)
testEmailRecipient String @default("")
// Registration settings
enablePublicRegistration Boolean @default(true)
enableEmailVerification Boolean @default(true)
autoApproveVerifiedUsers Boolean @default(true)
// Feature toggles
enableInfluence Boolean @default(true)
enableMap Boolean @default(true)
@@ -1350,6 +1378,7 @@ model Video {
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String? // videos|curated|compilations|playback|highlights
isShort Boolean @default(false) @map("is_short")
// Moderation system
isLocked Boolean @default(false) @map("is_locked")
@@ -1417,6 +1446,7 @@ model Video {
@@index([directoryType, isValid, orientation], map: "idx_videos_directory_valid_orientation")
@@index([isPublished, isLocked], map: "idx_videos_published_locked")
@@index([category, isPublished], map: "idx_videos_category_published")
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
@@index([uploaderId], map: "idx_videos_uploader")
@@map("videos")
}
@@ -1938,7 +1968,7 @@ model PlaylistVideo {
addedAt DateTime @default(now()) @map("added_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id])
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
media Video @relation(fields: [mediaId], references: [id])
@@index([playlistId], map: "idx_playlist_videos_playlist")
@@ -1955,7 +1985,7 @@ model FeaturedPlaylist {
featuredAt DateTime? @map("featured_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id])
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
featurer User? @relation("FeaturedPlaylistFeaturer", fields: [featuredBy], references: [id])
@@index([position], map: "idx_featured_playlists_position")
@@ -1969,7 +1999,7 @@ model PlaylistView {
createdAt DateTime @default(now()) @map("created_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id])
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
session Session @relation(fields: [sessionId], references: [id])
@@index([playlistId], map: "idx_playlist_views_playlist")

View File

@@ -41,6 +41,7 @@ async function main() {
password: hashedPassword,
name: 'Admin',
role: UserRole.SUPER_ADMIN,
roles: JSON.parse(JSON.stringify([UserRole.SUPER_ADMIN])),
emailVerified: true,
},
});

View File

@@ -114,6 +114,11 @@ const envSchema = z.object({
// NAR (National Address Register)
NAR_DATA_DIR: z.string().default('/data'),
// Overpass / Area Import
OVERPASS_API_URL: z.string().default('https://overpass-api.de/api/interpreter'),
OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000),
AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500),
// Media Management
ENABLE_MEDIA_FEATURES: z.string().default('false'),
MEDIA_API_PORT: z.coerce.number().default(4100),

View File

@@ -8,11 +8,23 @@ import { videoStreamingRoutes } from './modules/media/routes/video-streaming.rou
import { reactionsRoutes } from './modules/media/routes/reactions.routes';
import { publicRoutes } from './modules/media/routes/public.routes';
import { chatStreamRoutes } from './modules/media/routes/chat-stream.routes';
import { commentsRoutes } from './modules/media/routes/comments.routes';
import { uploadRoutes } from './modules/media/routes/upload.routes';
import { videoActionsRoutes } from './modules/media/routes/video-actions.routes';
import { videoScheduleRoutes } from './modules/media/routes/video-schedule.routes';
import { videoTrackingRoutes } from './modules/media/routes/video-tracking.routes';
import { commentAdminRoutes } from './modules/media/routes/comment-admin.routes';
import { chatNotificationsRoutes } from './modules/media/routes/chat-notifications.routes';
import { chatThreadsRoutes } from './modules/media/routes/chat-threads.routes';
import { userProfileRoutes } from './modules/media/routes/user-profile.routes';
import { shortsRoutes } from './modules/media/routes/shorts.routes';
import { upvoteRoutes } from './modules/media/routes/upvote.routes';
import { videoScheduleQueueService } from './services/video-schedule-queue.service';
import { videoFetchQueueService } from './services/video-fetch-queue.service';
import { fetchRoutes } from './modules/media/routes/fetch.routes';
import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes';
import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes';
import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@@ -32,6 +44,7 @@ const fastify = Fastify({
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
await videoScheduleQueueService.close();
await videoFetchQueueService.close();
fastify.close(() => {
logger.info('Media API server closed');
process.exit(0);
@@ -99,9 +112,18 @@ const start = async () => {
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
await fastify.register(publicRoutes, { prefix: '/api' });
await fastify.register(chatStreamRoutes, { prefix: '/api/media' });
// TODO: Add more routes
// await fastify.register(jobsRoutes, { prefix: '/api/jobs' });
await fastify.register(commentsRoutes, { prefix: '/api' });
await fastify.register(chatStreamRoutes, { prefix: '/api' });
await fastify.register(commentAdminRoutes, { prefix: '/api/media' });
await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' });
await fastify.register(chatThreadsRoutes, { prefix: '/api/media' });
await fastify.register(userProfileRoutes, { prefix: '/api/media' });
await fastify.register(fetchRoutes, { prefix: '/api/videos' });
await fastify.register(shortsRoutes, { prefix: '/api' });
await fastify.register(upvoteRoutes, { prefix: '/api' });
await fastify.register(playlistsPublicRoutes, { prefix: '/api/playlists' });
await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' });
await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' });
const port = env.MEDIA_API_PORT;
const host = '0.0.0.0';
@@ -113,6 +135,10 @@ const start = async () => {
videoScheduleQueueService.startWorker();
logger.info('Video schedule queue worker initialized');
// Start video fetch queue worker
videoFetchQueueService.startWorker();
logger.info('Video fetch queue worker initialized');
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
}

View File

@@ -8,6 +8,7 @@ interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
export function authenticate(req: Request, _res: Response, next: NextFunction) {
@@ -20,7 +21,12 @@ export function authenticate(req: Request, _res: Response, next: NextFunction) {
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
req.user = { id: payload.id, email: payload.email, role: payload.role };
req.user = {
id: payload.id,
email: payload.email,
role: payload.role,
roles: payload.roles || [payload.role], // Backwards compat: old JWTs without roles
};
next();
} catch {
throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN');
@@ -38,7 +44,12 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction) {
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
req.user = { id: payload.id, email: payload.email, role: payload.role };
req.user = {
id: payload.id,
email: payload.email,
role: payload.role,
roles: payload.roles || [payload.role],
};
} catch {
// Token invalid — continue without user
}

View File

@@ -8,7 +8,11 @@ export function requireRole(...roles: UserRole[]) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
if (!roles.includes(req.user.role)) {
// Check multi-role array (falls back to single role via auth middleware)
const userRoles = req.user.roles || [req.user.role];
const hasRole = userRoles.some(r => roles.includes(r));
if (!hasRole) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
@@ -21,7 +25,9 @@ export function requireNonTemp(req: Request, _res: Response, next: NextFunction)
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
if (req.user.role === UserRole.TEMP) {
const userRoles = req.user.roles || [req.user.role];
// User is "temp only" if their only role is TEMP
if (userRoles.length === 1 && userRoles[0] === UserRole.TEMP) {
throw new AppError(403, 'Temporary accounts cannot access this resource', 'TEMP_FORBIDDEN');
}

View File

@@ -0,0 +1,43 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
/** 3 requests per hour for resending verification emails */
export function createVerificationRateLimit() {
return rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:verify-resend:',
}),
message: {
error: {
message: 'Too many verification email requests, please try again later',
code: 'VERIFICATION_RATE_LIMIT_EXCEEDED',
},
},
});
}
/** 3 requests per hour for password reset emails */
export function createResetRateLimit() {
return rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:password-reset:',
}),
message: {
error: {
message: 'Too many password reset requests, please try again later',
code: 'RESET_RATE_LIMIT_EXCEEDED',
},
},
});
}

View File

@@ -1,9 +1,20 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { UserRole, UserStatus } from '@prisma/client';
import { authService } from './auth.service';
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { authRateLimit } from '../../middleware/rate-limit';
import { prisma } from '../../config/database';
import { verificationTokenService } from '../../services/verification-token.service';
import { passwordResetTokenService } from '../../services/password-reset-token.service';
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
const router = Router();
@@ -37,6 +48,172 @@ router.post(
}
);
// POST /api/auth/verify-email
const verifyEmailSchema = z.object({ token: z.string().min(1) });
router.post(
'/verify-email',
authRateLimit,
validate(verifyEmailSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = req.body;
const result = await verificationTokenService.verifyToken(token);
if (!result.valid || !result.userId) {
res.status(400).json({
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
});
return;
}
const settings = await siteSettingsService.get();
const autoApprove = settings.autoApproveVerifiedUsers;
const newStatus = autoApprove ? UserStatus.ACTIVE : UserStatus.PENDING_APPROVAL;
await prisma.user.update({
where: { id: result.userId },
data: { emailVerified: true, status: newStatus },
});
// If not auto-approved, notify admins
if (!autoApprove) {
const user = await prisma.user.findUnique({ where: { id: result.userId } });
if (user) {
const admins = await prisma.user.findMany({
where: { role: UserRole.SUPER_ADMIN, status: UserStatus.ACTIVE },
select: { email: true },
});
if (admins.length > 0) {
await emailService.sendPendingApprovalNotification({
adminEmails: admins.map(a => a.email),
newUserEmail: user.email,
newUserName: user.name || '',
}).catch(err => logger.error('Failed to send approval notification:', err));
}
}
}
res.json({
verified: true,
approved: autoApprove,
message: autoApprove
? 'Email verified. You can now log in.'
: 'Email verified. Your account is pending admin approval.',
});
} catch (err) {
next(err);
}
}
);
// POST /api/auth/resend-verification
const resendVerificationSchema = z.object({ email: z.string().email() });
router.post(
'/resend-verification',
createVerificationRateLimit(),
validate(resendVerificationSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Always return success to prevent user enumeration
res.json({ message: 'If your email is registered and pending verification, a new verification link has been sent.' });
// Send asynchronously (don't block response)
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
if (user && user.status === UserStatus.PENDING_VERIFICATION) {
const token = await verificationTokenService.createToken(user.id);
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const verificationUrl = `${adminUrl}/verify-email?token=${token}`;
await emailService.sendVerificationEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
verificationUrl,
}).catch(err => logger.error('Failed to resend verification email:', err));
}
} catch (err) {
next(err);
}
}
);
// POST /api/auth/forgot-password
const forgotPasswordSchema = z.object({ email: z.string().email() });
router.post(
'/forgot-password',
createResetRateLimit(),
validate(forgotPasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Always return success to prevent user enumeration
res.json({ message: 'If your email is registered, a password reset link has been sent.' });
// Send asynchronously
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
if (user && user.status === UserStatus.ACTIVE) {
const token = await passwordResetTokenService.createToken(user.id);
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const resetUrl = `${adminUrl}/reset-password?token=${token}`;
await emailService.sendPasswordResetEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
resetUrl,
}).catch(err => logger.error('Failed to send password reset email:', err));
}
} catch (err) {
next(err);
}
}
);
// POST /api/auth/reset-password
const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one digit'),
});
router.post(
'/reset-password',
authRateLimit,
validate(resetPasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = req.body;
const result = await passwordResetTokenService.validateToken(token);
if (!result.valid || !result.userId) {
res.status(400).json({
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
});
return;
}
const hashedPassword = await bcrypt.hash(password, 12);
// Update password, mark token used, invalidate all refresh tokens
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: result.userId },
data: { password: hashedPassword },
});
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
});
await passwordResetTokenService.markTokenUsed(token);
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
} catch (err) {
next(err);
}
}
);
// POST /api/auth/refresh
router.post(
'/refresh',
@@ -73,7 +250,6 @@ router.get(
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { prisma } = await import('../../config/database');
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
@@ -82,6 +258,7 @@ router.get(
name: true,
phone: true,
role: true,
roles: true,
status: true,
permissions: true,
createdVia: true,

View File

@@ -5,12 +5,17 @@ import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
import { recordLoginAttempt } from '../../utils/metrics';
import { siteSettingsService } from '../settings/settings.service';
import { verificationTokenService } from '../../services/verification-token.service';
import { emailService } from '../../services/email.service';
import { getPrimaryRole } from '../../utils/roles';
import type { RegisterInput } from './auth.schemas';
interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles: UserRole[];
}
interface TokenPair {
@@ -18,6 +23,16 @@ interface TokenPair {
refreshToken: string;
}
type UserForToken = { id: string; email: string; role: UserRole; roles?: unknown };
/** Parse the roles JSON field into a UserRole[] array */
function parseRoles(user: UserForToken): UserRole[] {
if (Array.isArray(user.roles) && user.roles.length > 0) {
return user.roles as UserRole[];
}
return [user.role];
}
export const authService = {
async login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
@@ -32,6 +47,17 @@ export const authService = {
throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
}
// Status-specific errors
if (user.status === UserStatus.PENDING_VERIFICATION) {
recordLoginAttempt('failure');
throw new AppError(403, 'Please verify your email address before logging in', 'EMAIL_NOT_VERIFIED');
}
if (user.status === UserStatus.PENDING_APPROVAL) {
recordLoginAttempt('failure');
throw new AppError(403, 'Your account is pending admin approval', 'ACCOUNT_PENDING');
}
if (user.status !== UserStatus.ACTIVE) {
recordLoginAttempt('failure');
throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
@@ -56,6 +82,12 @@ export const authService = {
},
async register(data: RegisterInput) {
// Check if public registration is enabled
const settings = await siteSettingsService.get();
if (!settings.enablePublicRegistration) {
throw new AppError(403, 'Public registration is currently disabled', 'REGISTRATION_DISABLED');
}
const existing = await prisma.user.findUnique({ where: { email: data.email } });
if (existing) {
throw new AppError(409, 'Email already registered', 'EMAIL_EXISTS');
@@ -63,16 +95,45 @@ export const authService = {
const hashedPassword = await bcrypt.hash(data.password, 12);
// Determine if email verification is needed
const smtpReady = await emailService.isSmtpConfigured();
const requireVerification = settings.enableEmailVerification && smtpReady;
const user = await prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone,
role: UserRole.USER, // Always USER for public registration
role: UserRole.USER,
roles: JSON.parse(JSON.stringify([UserRole.USER])),
status: requireVerification ? UserStatus.PENDING_VERIFICATION : UserStatus.ACTIVE,
emailVerified: !requireVerification,
createdVia: 'SELF_REGISTRATION',
},
});
// If verification required, send email and don't issue tokens
if (requireVerification) {
const token = await verificationTokenService.createToken(user.id);
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const verificationUrl = `${adminUrl}/verify-email?token=${token}`;
await emailService.sendVerificationEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
verificationUrl,
});
const { password: _, ...userWithoutPassword } = user;
return {
user: userWithoutPassword,
requiresVerification: true,
message: 'Please check your email to verify your account',
};
}
// No verification needed — issue tokens immediately
const tokens = await this.generateTokenPair(user);
const { password: _, ...userWithoutPassword } = user;
@@ -105,12 +166,13 @@ export const authService = {
const tokens = await prisma.$transaction(async (tx) => {
await tx.refreshToken.delete({ where: { id: stored.id } });
// Generate new token pair
const userRoles = parseRoles(stored.user);
const accessToken = this.generateAccessToken(stored.user);
const refreshPayload: TokenPayload = {
id: stored.user.id,
email: stored.user.email,
role: stored.user.role
role: getPrimaryRole(userRoles),
roles: userRoles,
};
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
@@ -139,20 +201,31 @@ export const authService = {
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
},
generateAccessToken(user: { id: string; email: string; role: UserRole }): string {
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
generateAccessToken(user: UserForToken): string {
const userRoles = parseRoles(user);
const payload: TokenPayload = {
id: user.id,
email: user.email,
role: getPrimaryRole(userRoles),
roles: userRoles,
};
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'],
});
},
async generateRefreshToken(user: { id: string; email: string; role: UserRole }): Promise<string> {
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
async generateRefreshToken(user: UserForToken): Promise<string> {
const userRoles = parseRoles(user);
const payload: TokenPayload = {
id: user.id,
email: user.email,
role: getPrimaryRole(userRoles),
roles: userRoles,
};
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
});
// Parse expiry to get a Date
const decoded = jwt.decode(token) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
@@ -167,7 +240,7 @@ export const authService = {
return token;
},
async generateTokenPair(user: { id: string; email: string; role: UserRole }): Promise<TokenPair> {
async generateTokenPair(user: UserForToken): Promise<TokenPair> {
const accessToken = this.generateAccessToken(user);
const refreshToken = await this.generateRefreshToken(user);
return { accessToken, refreshToken };

View File

@@ -0,0 +1,124 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import {
getDashboardSummary,
getSystemInfo,
getContainerStatuses,
getWeather,
getApiMetrics,
getTimeSeries,
getContainerResources,
} from './dashboard.service';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
// GET /api/dashboard/summary — platform counts
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {
try {
const summary = await getDashboardSummary();
res.json(summary);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/system — hardware + OS info (SUPER_ADMIN only)
router.get('/system', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const info = getSystemInfo();
res.json(info);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/containers — Docker container statuses (SUPER_ADMIN only)
router.get('/containers', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const containers = await getContainerStatuses();
res.json(containers);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/weather — weather from map settings location
router.get('/weather', async (_req: Request, res: Response, next: NextFunction) => {
try {
const weather = await getWeather();
if (!weather) {
res.json({ error: 'No location configured or weather unavailable' });
return;
}
res.json(weather);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/api-metrics — Prometheus API performance metrics (SUPER_ADMIN only)
router.get('/api-metrics', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const metrics = await getApiMetrics();
if (!metrics) {
res.json({ error: 'Prometheus unavailable' });
return;
}
res.json(metrics);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/time-series — predefined-key time-series from Prometheus (SUPER_ADMIN only)
const ALLOWED_METRIC_KEYS = new Set([
'request_rate_2xx', 'request_rate_4xx', 'request_rate_5xx',
'latency_p50', 'latency_p95', 'latency_p99',
'email_sent_rate', 'email_failed_rate', 'email_queue_size',
'cpu_usage', 'memory_usage', 'active_sessions', 'login_rate',
]);
const ALLOWED_RANGES = new Set(['1h', '6h', '24h']);
const ALLOWED_STEPS = new Set(['1m', '5m', '15m']);
router.get('/time-series', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const metricsParam = (req.query.metrics as string) || '';
const range = (req.query.range as string) || '1h';
const step = (req.query.step as string) || '5m';
if (!ALLOWED_RANGES.has(range)) {
res.status(400).json({ error: 'Invalid range. Allowed: 1h, 6h, 24h' });
return;
}
if (!ALLOWED_STEPS.has(step)) {
res.status(400).json({ error: 'Invalid step. Allowed: 1m, 5m, 15m' });
return;
}
const metricKeys = metricsParam.split(',').filter(k => ALLOWED_METRIC_KEYS.has(k.trim()));
if (metricKeys.length === 0) {
res.status(400).json({ error: 'No valid metric keys provided' });
return;
}
const data = await getTimeSeries(metricKeys, range, step);
res.json(data);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/container-resources — cAdvisor container CPU/memory/network (SUPER_ADMIN only)
router.get('/container-resources', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const containers = await getContainerResources();
res.json({ containers });
} catch (err) {
next(err);
}
});
export const dashboardRouter = router;

View File

@@ -0,0 +1,621 @@
import os from 'os';
import fs from 'fs';
import { prisma } from '../../config/database';
import { dockerService } from '../../services/docker.service';
import { mapSettingsService } from '../map/settings/settings.service';
import { env } from '../../config/env';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout';
import { validatePromQLQueries } from '../../utils/promql-validator';
import { isServiceOnline } from '../../utils/health-check';
import { logger } from '../../utils/logger';
// --- Types ---
export interface DashboardSummary {
users: {
total: number;
byRole: Record<string, number>;
active: number;
suspended: number;
};
campaigns: {
total: number;
active: number;
draft: number;
paused: number;
archived: number;
};
locations: {
total: number;
geocoded: number;
addresses: number;
};
emails: {
total: number;
sent: number;
failed: number;
queued: number;
};
shifts: {
total: number;
open: number;
upcoming: number;
};
canvass: {
totalSessions: number;
totalVisits: number;
activeSessions: number;
};
responses: {
total: number;
pending: number;
approved: number;
};
videos: {
total: number;
published: number;
};
pages: {
total: number;
published: number;
};
emailTemplates: {
total: number;
};
cuts: {
total: number;
};
representatives: {
totalCached: number;
};
campaignModeration: {
pendingReview: number;
};
}
export interface SystemInfo {
hostname: string;
platform: string;
arch: string;
nodeVersion: string;
uptime: number; // seconds
cpu: {
model: string;
cores: number;
loadAvg: number[]; // 1, 5, 15 min
};
memory: {
totalMB: number;
usedMB: number;
freeMB: number;
usagePercent: number;
};
disk: {
totalGB: number;
usedGB: number;
freeGB: number;
usagePercent: number;
} | null;
process: {
heapUsedMB: number;
heapTotalMB: number;
rssMB: number;
uptimeSeconds: number;
};
}
export interface ContainerInfo {
name: string;
label: string;
running: boolean;
status: string;
}
export interface WeatherData {
latitude: number;
longitude: number;
temperature: number;
apparentTemperature: number;
humidity: number;
windSpeed: number;
windDirection: number;
weatherCode: number;
weatherDescription: string;
isDay: boolean;
precipitation: number;
cloudCover: number;
time: string;
}
// --- Container definitions ---
const CONTAINERS: { name: string; label: string }[] = [
{ name: 'changemaker-v2-api', label: 'API' },
{ name: 'changemaker-media-api', label: 'Media API' },
{ name: 'changemaker-v2-admin', label: 'Admin' },
{ name: 'changemaker-v2-postgres', label: 'PostgreSQL' },
{ name: 'redis-changemaker', label: 'Redis' },
{ name: 'changemaker-v2-nginx', label: 'Nginx' },
{ name: 'changemaker-v2-nocodb', label: 'NocoDB' },
{ name: 'listmonk-app', label: 'Listmonk' },
{ name: 'n8n-changemaker', label: 'n8n' },
{ name: 'gitea-changemaker', label: 'Gitea' },
{ name: 'mailhog-changemaker', label: 'MailHog' },
{ name: 'mini-qr', label: 'Mini QR' },
{ name: 'code-server-changemaker', label: 'Code Server' },
{ name: 'mkdocs-changemaker', label: 'MkDocs' },
{ name: 'newt-changemaker', label: 'Newt Tunnel' },
];
// --- WMO weather code descriptions ---
const WMO_CODES: Record<number, string> = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Depositing rime fog',
51: 'Light drizzle',
53: 'Moderate drizzle',
55: 'Dense drizzle',
56: 'Light freezing drizzle',
57: 'Dense freezing drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
66: 'Light freezing rain',
67: 'Heavy freezing rain',
71: 'Slight snowfall',
73: 'Moderate snowfall',
75: 'Heavy snowfall',
77: 'Snow grains',
80: 'Slight rain showers',
81: 'Moderate rain showers',
82: 'Violent rain showers',
85: 'Slight snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with slight hail',
99: 'Thunderstorm with heavy hail',
};
// --- Service functions ---
export async function getDashboardSummary(): Promise<DashboardSummary> {
const now = new Date();
const [
usersTotal, usersSuperAdmin, usersInfluenceAdmin, usersMapAdmin, usersUser, usersTemp,
usersActive, usersSuspended,
campaignsTotal, campaignsActive, campaignsDraft, campaignsPaused, campaignsArchived,
locationsTotal, locationsGeocoded, addressesTotal,
emailsTotal, emailsSent, emailsFailed, emailsQueued,
shiftsTotal, shiftsOpen, shiftsUpcoming,
canvassSessions, canvassVisits, canvassActive,
responsesTotal, responsesPending, responsesApproved,
videosTotal, videosPublished,
pagesTotal, pagesPublished,
emailTemplatesTotal,
cutsTotal,
repsCached,
campaignsPendingReview,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { role: 'SUPER_ADMIN' } }),
prisma.user.count({ where: { role: 'INFLUENCE_ADMIN' } }),
prisma.user.count({ where: { role: 'MAP_ADMIN' } }),
prisma.user.count({ where: { role: 'USER' } }),
prisma.user.count({ where: { role: 'TEMP' } }),
prisma.user.count({ where: { status: 'ACTIVE' } }),
prisma.user.count({ where: { status: 'SUSPENDED' } }),
prisma.campaign.count(),
prisma.campaign.count({ where: { status: 'ACTIVE' } }),
prisma.campaign.count({ where: { status: 'DRAFT' } }),
prisma.campaign.count({ where: { status: 'PAUSED' } }),
prisma.campaign.count({ where: { status: 'ARCHIVED' } }),
prisma.location.count(),
prisma.location.count({ where: { geocodeProvider: { not: null } } }),
prisma.address.count(),
prisma.campaignEmail.count(),
prisma.campaignEmail.count({ where: { status: 'SENT' } }),
prisma.campaignEmail.count({ where: { status: 'FAILED' } }),
prisma.campaignEmail.count({ where: { status: 'QUEUED' } }),
prisma.shift.count(),
prisma.shift.count({ where: { status: 'OPEN' } }),
prisma.shift.count({ where: { date: { gte: now }, status: { not: 'CANCELLED' } } }),
prisma.canvassSession.count(),
prisma.canvassVisit.count(),
prisma.canvassSession.count({ where: { status: 'ACTIVE' } }),
prisma.representativeResponse.count(),
prisma.representativeResponse.count({ where: { status: 'PENDING' } }),
prisma.representativeResponse.count({ where: { status: 'APPROVED' } }),
prisma.video.count(),
prisma.video.count({ where: { isPublished: true } }),
prisma.landingPage.count(),
prisma.landingPage.count({ where: { published: true } }),
prisma.emailTemplate.count(),
prisma.cut.count(),
prisma.representative.count(),
prisma.campaign.count({ where: { moderationStatus: 'PENDING_REVIEW' } }),
]);
return {
users: {
total: usersTotal,
byRole: {
SUPER_ADMIN: usersSuperAdmin, INFLUENCE_ADMIN: usersInfluenceAdmin,
MAP_ADMIN: usersMapAdmin, USER: usersUser, TEMP: usersTemp,
},
active: usersActive, suspended: usersSuspended,
},
campaigns: { total: campaignsTotal, active: campaignsActive, draft: campaignsDraft, paused: campaignsPaused, archived: campaignsArchived },
locations: { total: locationsTotal, geocoded: locationsGeocoded, addresses: addressesTotal },
emails: { total: emailsTotal, sent: emailsSent, failed: emailsFailed, queued: emailsQueued },
shifts: { total: shiftsTotal, open: shiftsOpen, upcoming: shiftsUpcoming },
canvass: { totalSessions: canvassSessions, totalVisits: canvassVisits, activeSessions: canvassActive },
responses: { total: responsesTotal, pending: responsesPending, approved: responsesApproved },
videos: { total: videosTotal, published: videosPublished },
pages: { total: pagesTotal, published: pagesPublished },
emailTemplates: { total: emailTemplatesTotal },
cuts: { total: cutsTotal },
representatives: { totalCached: repsCached },
campaignModeration: { pendingReview: campaignsPendingReview },
};
}
export function getSystemInfo(): SystemInfo {
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpus = os.cpus();
const mem = process.memoryUsage();
let disk: SystemInfo['disk'] = null;
try {
const stats = fs.statfsSync('/');
const totalBytes = stats.bsize * stats.blocks;
const freeBytes = stats.bsize * stats.bavail;
const usedBytes = totalBytes - freeBytes;
disk = {
totalGB: Math.round((totalBytes / 1073741824) * 10) / 10,
usedGB: Math.round((usedBytes / 1073741824) * 10) / 10,
freeGB: Math.round((freeBytes / 1073741824) * 10) / 10,
usagePercent: Math.round((usedBytes / totalBytes) * 100),
};
} catch {
// statfsSync may not be available on all platforms
}
return {
hostname: os.hostname(),
platform: `${os.type()} ${os.release()}`,
arch: os.arch(),
nodeVersion: process.version,
uptime: Math.floor(os.uptime()),
cpu: {
model: cpus[0]?.model?.trim() || 'Unknown',
cores: cpus.length,
loadAvg: os.loadavg().map(v => Math.round(v * 100) / 100),
},
memory: {
totalMB: Math.round(totalMem / 1048576),
usedMB: Math.round(usedMem / 1048576),
freeMB: Math.round(freeMem / 1048576),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk,
process: {
heapUsedMB: Math.round(mem.heapUsed / 1048576),
heapTotalMB: Math.round(mem.heapTotal / 1048576),
rssMB: Math.round(mem.rss / 1048576),
uptimeSeconds: Math.floor(process.uptime()),
},
};
}
export async function getContainerStatuses(): Promise<ContainerInfo[]> {
const results = await Promise.all(
CONTAINERS.map(async (c) => {
try {
const status = await dockerService.getContainerStatus(c.name);
return { name: c.name, label: c.label, running: status.running, status: status.status };
} catch {
return { name: c.name, label: c.label, running: false, status: 'unknown' };
}
}),
);
return results;
}
export async function getWeather(): Promise<WeatherData | null> {
try {
const settings = await mapSettingsService.get();
const lat = settings.latitude ? Number(settings.latitude) : null;
const lng = settings.longitude ? Number(settings.longitude) : null;
if (!lat || !lng) return null;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,is_day&temperature_unit=celsius&wind_speed_unit=kmh`;
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!response.ok) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await response.json() as { current: Record<string, any> };
const current = data.current;
return {
latitude: lat,
longitude: lng,
temperature: current.temperature_2m,
apparentTemperature: current.apparent_temperature,
humidity: current.relative_humidity_2m,
windSpeed: current.wind_speed_10m,
windDirection: current.wind_direction_10m,
weatherCode: current.weather_code,
weatherDescription: WMO_CODES[current.weather_code] || 'Unknown',
isDay: current.is_day === 1,
precipitation: current.precipitation,
cloudCover: current.cloud_cover,
time: current.time,
};
} catch (err) {
logger.warn('Failed to fetch weather data', err);
return null;
}
}
// --- Time-Series from Prometheus ---
/** Predefined metric key → PromQL mapping (prevents PromQL injection) */
const METRIC_QUERIES: Record<string, string> = {
request_rate_2xx: 'sum(rate(http_requests_total{status_code=~"2.."}[5m]))',
request_rate_4xx: 'sum(rate(http_requests_total{status_code=~"4.."}[5m]))',
request_rate_5xx: 'sum(rate(http_requests_total{status_code=~"5.."}[5m]))',
latency_p50: 'histogram_quantile(0.50, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
latency_p95: 'histogram_quantile(0.95, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
latency_p99: 'histogram_quantile(0.99, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
email_sent_rate: 'rate(cm_emails_sent_total[5m])',
email_failed_rate: 'rate(cm_emails_failed_total[5m])',
email_queue_size: 'cm_email_queue_size',
cpu_usage: '100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
memory_usage: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
active_sessions: 'cm_active_sessions',
login_rate: 'rate(cm_login_attempts_total[5m])',
};
const ALLOWED_RANGES: Record<string, number> = { '1h': 3600, '6h': 21600, '24h': 86400 };
const ALLOWED_STEPS: Record<string, string> = { '1m': '60', '5m': '300', '15m': '900' };
export interface TimeSeriesPoint {
timestamps: number[];
values: number[];
}
export type TimeSeriesResult = Record<string, TimeSeriesPoint>;
export async function getTimeSeries(
metricKeys: string[],
range: string,
step: string,
): Promise<TimeSeriesResult> {
const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`);
if (!online) return {};
const rangeSec = ALLOWED_RANGES[range];
const stepSec = ALLOWED_STEPS[step];
if (!rangeSec || !stepSec) return {};
const now = Math.floor(Date.now() / 1000);
const start = now - rangeSec;
const result: TimeSeriesResult = {};
await Promise.all(
metricKeys
.filter(k => METRIC_QUERIES[k])
.map(async (key) => {
try {
const query = METRIC_QUERIES[key];
const url = `${env.PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${now}&step=${stepSec}`;
const response = await fetchWithTimeout(url, {}, 8000);
const data = await response.json() as {
data?: { result?: Array<{ values?: Array<[number, string]> }> };
};
const values = data?.data?.result?.[0]?.values || [];
result[key] = {
timestamps: values.map(v => v[0]),
values: values.map(v => {
const n = parseFloat(v[1]);
return isNaN(n) || !isFinite(n) ? 0 : Math.round(n * 1000) / 1000;
}),
};
} catch (err) {
logger.debug(`Failed to fetch time-series for ${key}`, err);
result[key] = { timestamps: [], values: [] };
}
}),
);
return result;
}
// --- Container Resources from cAdvisor Prometheus metrics ---
export interface ContainerResource {
name: string;
label: string;
cpuPercent: number;
memoryMB: number;
memoryLimitMB: number;
networkRxKBps: number;
networkTxKBps: number;
}
export async function getContainerResources(): Promise<ContainerResource[]> {
const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`);
if (!online) return [];
const containerNames = CONTAINERS.map(c => c.name).join('|');
const nameFilter = `name=~"${containerNames}"`;
const queries = [
`rate(container_cpu_usage_seconds_total{${nameFilter}}[1m])`,
`container_memory_usage_bytes{${nameFilter}}`,
`container_spec_memory_limit_bytes{${nameFilter}}`,
`rate(container_network_receive_bytes_total{${nameFilter}}[1m])`,
`rate(container_network_transmit_bytes_total{${nameFilter}}[1m])`,
];
try {
const results = await Promise.all(
queries.map(async (q) => {
const response = await fetchWithTimeout(
`${env.PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(q)}`,
{},
5000,
);
const data = await response.json() as {
data?: { result?: Array<{ metric?: { name?: string }; value?: [number, string] }> };
};
return data?.data?.result || [];
}),
);
const [cpuResults, memResults, memLimitResults, rxResults, txResults] = results;
const getVal = (results: typeof cpuResults, containerName: string): number => {
const item = results.find(r => r.metric?.name === containerName);
const v = parseFloat(item?.value?.[1] || '0');
return isNaN(v) || !isFinite(v) ? 0 : v;
};
return CONTAINERS.map(c => ({
name: c.name,
label: c.label,
cpuPercent: Math.round(getVal(cpuResults, c.name) * 100 * 100) / 100,
memoryMB: Math.round(getVal(memResults, c.name) / 1048576),
memoryLimitMB: Math.round(getVal(memLimitResults, c.name) / 1048576),
networkRxKBps: Math.round(getVal(rxResults, c.name) / 1024 * 100) / 100,
networkTxKBps: Math.round(getVal(txResults, c.name) / 1024 * 100) / 100,
}));
} catch (err) {
logger.warn('Failed to fetch container resources', err);
return [];
}
}
// --- API Metrics from Prometheus ---
export interface ApiMetrics {
requestRate: number; // req/s over last 5m
errorRate: number; // 4xx+5xx rate
avgLatencyMs: number; // average response time
p95LatencyMs: number; // 95th percentile
topRoutes: { method: string; route: string; count: number }[];
slowRoutes: { method: string; route: string; p95Ms: number }[];
statusBreakdown: { status: string; count: number }[];
}
async function queryPrometheus(query: string): Promise<any> {
const response = await fetchWithTimeout(
`${env.PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`,
{},
5000,
);
return response.json();
}
export async function getApiMetrics(): Promise<ApiMetrics | null> {
try {
const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`);
if (!online) return null;
const queries = [
// Total request rate
'sum(rate(http_requests_total[5m]))',
// Error rate (4xx + 5xx)
'sum(rate(http_requests_total{status_code=~"[45].."}[5m]))',
// Average latency
'sum(rate(http_request_duration_seconds_sum[5m])) / sum(rate(http_request_duration_seconds_count[5m]))',
// P95 latency
'histogram_quantile(0.95, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
// Top 10 routes by request count (last 15m for more data)
'topk(10, sum by(method, route) (increase(http_requests_total[15m])))',
// Slowest 10 routes by p95
'topk(10, histogram_quantile(0.95, sum by(method, route, le) (rate(http_request_duration_seconds_bucket[5m]))))',
// Status code breakdown
'sum by(status_code) (increase(http_requests_total[15m]))',
];
validatePromQLQueries(queries);
const [rateRes, errorRes, avgLatRes, p95Res, topRes, slowRes, statusRes] = await Promise.all(
queries.map(q => queryPrometheus(q).catch(() => null)),
);
const extractScalar = (res: any): number => {
const val = parseFloat(res?.data?.result?.[0]?.value?.[1] || '0');
return isNaN(val) || !isFinite(val) ? 0 : val;
};
// Parse top routes
const topRoutes: ApiMetrics['topRoutes'] = [];
for (const item of (topRes?.data?.result || [])) {
const route = item.metric?.route || '';
// Skip internal routes
if (route === '/api/metrics' || route === '/api/health') continue;
const count = parseFloat(item.value?.[1] || '0');
if (count > 0) {
topRoutes.push({
method: item.metric?.method || '?',
route,
count: Math.round(count),
});
}
}
// Parse slow routes
const slowRoutes: ApiMetrics['slowRoutes'] = [];
for (const item of (slowRes?.data?.result || [])) {
const route = item.metric?.route || '';
if (route === '/api/metrics' || route === '/api/health') continue;
const p95 = parseFloat(item.value?.[1] || '0');
if (p95 > 0 && isFinite(p95)) {
slowRoutes.push({
method: item.metric?.method || '?',
route,
p95Ms: Math.round(p95 * 1000),
});
}
}
slowRoutes.sort((a, b) => b.p95Ms - a.p95Ms);
// Parse status breakdown
const statusBreakdown: ApiMetrics['statusBreakdown'] = [];
for (const item of (statusRes?.data?.result || [])) {
const count = parseFloat(item.value?.[1] || '0');
if (count > 0) {
statusBreakdown.push({
status: item.metric?.status_code || '?',
count: Math.round(count),
});
}
}
statusBreakdown.sort((a, b) => b.count - a.count);
return {
requestRate: Math.round(extractScalar(rateRes) * 100) / 100,
errorRate: Math.round(extractScalar(errorRes) * 100) / 100,
avgLatencyMs: Math.round(extractScalar(avgLatRes) * 1000),
p95LatencyMs: Math.round(extractScalar(p95Res) * 1000),
topRoutes: topRoutes.slice(0, 8),
slowRoutes: slowRoutes.slice(0, 5),
statusBreakdown,
};
} catch (err) {
logger.warn('Failed to fetch API metrics from Prometheus', err);
return null;
}
}

View File

@@ -0,0 +1,58 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignsService } from './campaigns.service';
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
// GET /api/campaigns/moderation/queue — list moderation queue
router.get(
'/moderation/queue',
validate(listModerationQueueSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await campaignsService.findModerationQueue(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/campaigns/moderation/stats — moderation stats
router.get(
'/moderation/stats',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const stats = await campaignsService.getModerationStats();
res.json(stats);
} catch (err) {
next(err);
}
}
);
// PATCH /api/campaigns/moderation/:id — moderate a campaign
router.patch(
'/moderation/:id',
validate(moderateCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
res.json(campaign);
} catch (err) {
next(err);
}
}
);
export { router as campaignModerationRouter };

View File

@@ -0,0 +1,77 @@
import { Router, Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../../config/redis';
import { campaignsService } from './campaigns.service';
import { createUserCampaignSchema, updateUserCampaignSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireNonTemp } from '../../../middleware/rbac.middleware';
const campaignSubmitRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:campaign-submit:',
}),
message: {
error: {
message: 'Too many campaign submissions, please try again later',
code: 'CAMPAIGN_SUBMIT_RATE_LIMIT_EXCEEDED',
},
},
});
const router = Router();
// All user campaign routes require auth + non-temp
router.use(authenticate);
router.use(requireNonTemp);
// POST /api/campaigns/user/submit — create a user-generated campaign
router.post(
'/user/submit',
campaignSubmitRateLimit,
validate(createUserCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await campaignsService.createUserCampaign(req.body, req.user!);
res.status(201).json(campaign);
} catch (err) {
next(err);
}
}
);
// GET /api/campaigns/user/my-campaigns — list own campaigns
router.get(
'/user/my-campaigns',
async (req: Request, res: Response, next: NextFunction) => {
try {
const campaigns = await campaignsService.findUserCampaigns(req.user!.id);
res.json(campaigns);
} catch (err) {
next(err);
}
}
);
// PUT /api/campaigns/user/:id — edit own campaign
router.put(
'/user/:id',
validate(updateUserCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.updateUserCampaign(id, req.body, req.user!);
res.json(campaign);
} catch (err) {
next(err);
}
}
);
export { router as campaignUserRouter };

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { CampaignStatus, GovernmentLevel } from '@prisma/client';
import { CampaignStatus, CampaignModerationStatus, GovernmentLevel } from '@prisma/client';
export const createCampaignSchema = z.object({
title: z.string().min(1, 'Title is required'),
@@ -52,6 +52,38 @@ export const campaignIdSchema = z.object({
id: z.string().min(1),
});
// User-submitted campaign (restricted fields)
export const createUserCampaignSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(200),
description: z.string().max(2000).optional(),
emailSubject: z.string().min(3, 'Email subject is required').max(200),
emailBody: z.string().min(10, 'Email body must be at least 10 characters').max(5000),
callToAction: z.string().max(500).optional(),
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).min(1, 'Select at least one government level'),
});
// Update own user campaign (same restricted fields)
export const updateUserCampaignSchema = createUserCampaignSchema.partial();
// Admin moderation action
export const moderateCampaignSchema = z.object({
action: z.enum(['approve', 'reject', 'request_changes']),
reason: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
});
// Moderation queue filters
export const listModerationQueueSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
moderationStatus: z.nativeEnum(CampaignModerationStatus).optional(),
});
export type CreateCampaignInput = z.infer<typeof createCampaignSchema>;
export type UpdateCampaignInput = z.infer<typeof updateCampaignSchema>;
export type ListCampaignsInput = z.infer<typeof listCampaignsSchema>;
export type CreateUserCampaignInput = z.infer<typeof createUserCampaignSchema>;
export type UpdateUserCampaignInput = z.infer<typeof updateUserCampaignSchema>;
export type ModerateCampaignInput = z.infer<typeof moderateCampaignSchema>;
export type ListModerationQueueInput = z.infer<typeof listModerationQueueSchema>;

View File

@@ -1,7 +1,20 @@
import { Prisma, UserRole } from '@prisma/client';
import { Prisma, UserRole, CampaignModerationStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import type { CreateCampaignInput, UpdateCampaignInput, ListCampaignsInput } from './campaigns.schemas';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import type {
CreateCampaignInput, UpdateCampaignInput, ListCampaignsInput,
CreateUserCampaignInput, UpdateUserCampaignInput, ModerateCampaignInput, ListModerationQueueInput,
} from './campaigns.schemas';
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const campaignSelect = {
id: true,
@@ -26,6 +39,12 @@ const campaignSelect = {
createdByUserId: true,
createdByUserEmail: true,
createdByUserName: true,
isUserGenerated: true,
moderationStatus: true,
reviewedByUserId: true,
reviewedAt: true,
rejectionReason: true,
moderationNotes: true,
createdAt: true,
updatedAt: true,
_count: {
@@ -86,8 +105,7 @@ export const campaignsService = {
if (status) where.status = status;
// Non-admin users only see their own campaigns
const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
if (user && !adminRoles.includes(user.role)) {
if (user && !hasAnyRole(user, ADMIN_ROLES)) {
where.createdByUserId = user.id;
}
@@ -238,4 +256,177 @@ export const campaignsService = {
await prisma.campaign.delete({ where: { id } });
},
// --- User-Generated Campaign Methods ---
async createUserCampaign(data: CreateUserCampaignInput, user: AuthUser) {
const baseSlug = generateSlug(data.title);
const slug = await resolveSlugCollision(baseSlug);
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { name: true },
});
const campaign = await prisma.campaign.create({
data: {
slug,
title: escapeHtml(data.title),
description: data.description ? escapeHtml(data.description) : null,
emailSubject: escapeHtml(data.emailSubject),
emailBody: escapeHtml(data.emailBody),
callToAction: data.callToAction ? escapeHtml(data.callToAction) : null,
targetGovernmentLevels: data.targetGovernmentLevels,
status: 'DRAFT',
isUserGenerated: true,
moderationStatus: CampaignModerationStatus.PENDING_REVIEW,
allowSmtpEmail: false,
allowMailtoLink: true,
collectUserInfo: true,
showEmailCount: true,
showCallCount: false,
allowEmailEditing: false,
allowCustomRecipients: false,
showResponseWall: false,
highlightCampaign: false,
createdByUserId: user.id,
createdByUserEmail: user.email,
createdByUserName: dbUser?.name ?? null,
},
select: campaignSelect,
});
return campaign;
},
async findUserCampaigns(userId: string) {
return prisma.campaign.findMany({
where: { createdByUserId: userId, isUserGenerated: true },
select: campaignSelect,
orderBy: { createdAt: 'desc' },
});
},
async updateUserCampaign(id: string, data: Partial<CreateUserCampaignInput>, user: AuthUser) {
const existing = await prisma.campaign.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
if (existing.createdByUserId !== user.id) {
throw new AppError(403, 'You can only edit your own campaigns', 'FORBIDDEN');
}
if (!existing.isUserGenerated) {
throw new AppError(403, 'Cannot edit admin-created campaigns', 'FORBIDDEN');
}
if (
existing.moderationStatus !== CampaignModerationStatus.CHANGES_REQUESTED &&
existing.moderationStatus !== CampaignModerationStatus.PENDING_REVIEW
) {
throw new AppError(400, 'Campaign cannot be edited in its current state', 'INVALID_STATE');
}
const updateData: Prisma.CampaignUncheckedUpdateInput = {};
if (data.title) {
updateData.title = escapeHtml(data.title);
const baseSlug = generateSlug(data.title);
updateData.slug = await resolveSlugCollision(baseSlug, id);
}
if (data.description !== undefined) updateData.description = data.description ? escapeHtml(data.description) : null;
if (data.emailSubject) updateData.emailSubject = escapeHtml(data.emailSubject);
if (data.emailBody) updateData.emailBody = escapeHtml(data.emailBody);
if (data.callToAction !== undefined) updateData.callToAction = data.callToAction ? escapeHtml(data.callToAction) : null;
if (data.targetGovernmentLevels) updateData.targetGovernmentLevels = data.targetGovernmentLevels;
// Reset to pending review on edit
updateData.moderationStatus = CampaignModerationStatus.PENDING_REVIEW;
updateData.rejectionReason = null;
return prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
});
},
// --- Moderation Methods ---
async findModerationQueue(filters: ListModerationQueueInput) {
const { page, limit, search, moderationStatus } = filters;
const skip = (page - 1) * limit;
const where: Prisma.CampaignWhereInput = { isUserGenerated: true };
if (moderationStatus) where.moderationStatus = moderationStatus;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ createdByUserName: { contains: search, mode: 'insensitive' } },
{ createdByUserEmail: { contains: search, mode: 'insensitive' } },
];
}
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
select: campaignSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.campaign.count({ where }),
]);
return {
campaigns,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getModerationStats() {
const [total, pending, approved, rejected, changesRequested] = await Promise.all([
prisma.campaign.count({ where: { isUserGenerated: true } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.PENDING_REVIEW } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.APPROVED } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.REJECTED } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.CHANGES_REQUESTED } }),
]);
return { total, pending, approved, rejected, changesRequested };
},
async moderateCampaign(id: string, input: ModerateCampaignInput, reviewer: AuthUser) {
const existing = await prisma.campaign.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
if (!existing.isUserGenerated) {
throw new AppError(400, 'Only user-generated campaigns can be moderated', 'INVALID_STATE');
}
const updateData: Prisma.CampaignUncheckedUpdateInput = {
reviewedByUserId: reviewer.id,
reviewedAt: new Date(),
moderationNotes: input.notes ?? null,
};
switch (input.action) {
case 'approve':
updateData.moderationStatus = CampaignModerationStatus.APPROVED;
updateData.status = 'ACTIVE';
updateData.rejectionReason = null;
break;
case 'reject':
updateData.moderationStatus = CampaignModerationStatus.REJECTED;
updateData.rejectionReason = input.reason ?? null;
break;
case 'request_changes':
updateData.moderationStatus = CampaignModerationStatus.CHANGES_REQUESTED;
updateData.rejectionReason = input.reason ?? null;
break;
}
return prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
});
},
};

View File

@@ -189,18 +189,18 @@ volunteerRouter.post(
validate(volunteerCreateLocationSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const role = req.user!.role;
const data = { ...req.body };
// Strip fields based on role
const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some((r: string) => r === UserRole.SUPER_ADMIN || r === UserRole.MAP_ADMIN);
if (!isAdmin) {
delete data.firstName;
delete data.lastName;
delete data.email;
delete data.phone;
}
if (role === UserRole.TEMP) {
if (userRoles.length === 1 && userRoles[0] === UserRole.TEMP) {
delete data.supportLevel;
delete data.sign;
delete data.signSize;

View File

@@ -0,0 +1,89 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { randomUUID } from 'crypto';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { areaImportPreviewSchema, areaImportStartSchema } from './area-import.schemas';
import { areaImportService, type AreaImportProgress } from './area-import.service';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
const areaImportRouter = Router();
areaImportRouter.use(authenticate);
areaImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
// POST /api/map/area-import/preview — get bounds, estimates, and existing count
areaImportRouter.post(
'/preview',
validate(areaImportPreviewSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await areaImportService.previewAreaImport(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/map/area-import — start import (fire-and-forget, returns importId)
areaImportRouter.post(
'/',
validate(areaImportStartSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const importId = randomUUID();
const userId = req.user!.id;
// Write initial progress so status endpoint works immediately
const initialProgress: AreaImportProgress = {
status: 'initializing',
bounds: null,
areaSqKm: 0,
sources: {
osm: { status: 'pending', candidatesFound: 0 },
nar: { status: 'pending', candidatesFound: 0 },
reverseGeocode: { status: 'pending', candidatesFound: 0 },
},
locationsCreated: 0,
addressesCreated: 0,
skippedDuplicate: 0,
totalCandidates: 0,
};
await redis.set(`area-import:${importId}`, JSON.stringify(initialProgress), 'EX', 3600);
// Fire and forget
areaImportService.runAreaImport(userId, req.body, importId).catch((err) => {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
logger.error(`Area import ${importId} failed: ${errorMsg}`);
});
res.json({ importId });
} catch (err) {
next(err);
}
},
);
// GET /api/map/area-import/status/:importId — poll import progress
areaImportRouter.get(
'/status/:importId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const importId = req.params.importId as string;
const progress = await areaImportService.getProgress(importId);
if (!progress) {
res.status(404).json({ error: { message: 'Import not found or expired', code: 'NOT_FOUND' } });
return;
}
res.json(progress);
} catch (err) {
next(err);
}
},
);
export { areaImportRouter };

View File

@@ -0,0 +1,57 @@
import { z } from 'zod';
const sourcesSchema = z.object({
osm: z.boolean().default(false),
nar: z.union([
z.boolean(),
z.object({
residentialOnly: z.boolean().default(true),
}),
]).default(false),
reverseGeocode: z.union([
z.boolean(),
z.object({
gridSpacingMeters: z.number().min(20).max(500).default(100),
maxPoints: z.number().min(10).max(2000).default(500),
}),
]).default(false),
});
export const areaImportPreviewSchema = z.object({
areaType: z.enum(['cut', 'viewport']),
cutId: z.string().optional(),
center: z.object({ lat: z.number(), lng: z.number() }).optional(),
zoom: z.number().optional(),
viewportWidth: z.number().optional(),
viewportHeight: z.number().optional(),
sources: sourcesSchema,
}).refine(
(data) => {
if (data.areaType === 'cut') return !!data.cutId;
if (data.areaType === 'viewport') return data.center && data.zoom !== undefined;
return false;
},
{ message: 'Cut ID required for cut area type, center+zoom required for viewport type' },
);
export const areaImportStartSchema = z.object({
areaType: z.enum(['cut', 'viewport']),
cutId: z.string().optional(),
center: z.object({ lat: z.number(), lng: z.number() }).optional(),
zoom: z.number().optional(),
viewportWidth: z.number().optional(),
viewportHeight: z.number().optional(),
sources: sourcesSchema,
deduplicateRadius: z.number().min(0).max(100).default(5),
batchSize: z.number().int().min(100).max(5000).default(1000),
}).refine(
(data) => {
if (data.areaType === 'cut') return !!data.cutId;
if (data.areaType === 'viewport') return data.center && data.zoom !== undefined;
return false;
},
{ message: 'Cut ID required for cut area type, center+zoom required for viewport type' },
);
export type AreaImportPreviewInput = z.infer<typeof areaImportPreviewSchema>;
export type AreaImportStartInput = z.infer<typeof areaImportStartSchema>;

View File

@@ -0,0 +1,671 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
import { env } from '../../../config/env';
import {
calculateBounds,
parseGeoJsonPolygon,
boundsFromCenterZoom,
isPointInPolygon,
haversineDistance,
} from '../../../utils/spatial';
import { overpassService, type CandidateLocation } from './overpass.service';
import { narImportService } from './nar-import.service';
import { geocodingService } from '../geocoding/geocoding.service';
import type { AreaImportPreviewInput, AreaImportStartInput } from './area-import.schemas';
// ---- Types ----
export type SourceStatus = 'pending' | 'running' | 'complete' | 'failed' | 'skipped';
export interface AreaImportSourceProgress {
status: SourceStatus;
candidatesFound: number;
message?: string;
error?: string;
}
export interface AreaImportProgress {
status: 'initializing' | 'running' | 'creating-records' | 'complete' | 'failed';
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number } | null;
areaSqKm: number;
sources: {
osm: AreaImportSourceProgress;
nar: AreaImportSourceProgress;
reverseGeocode: AreaImportSourceProgress;
};
locationsCreated: number;
addressesCreated: number;
skippedDuplicate: number;
totalCandidates: number;
error?: string;
}
export interface AreaImportPreviewResult {
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
areaSqKm: number;
existingLocations: number;
estimates: {
osm: number;
nar: number;
reverseGeocode: number;
};
narProvincesDetected: string[];
}
// ---- Helpers ----
const PROGRESS_TTL = 3600; // 1 hour
const PROGRESS_KEY_PREFIX = 'area-import:';
function roundCoord(val: number, decimals: number = 5): number {
const factor = Math.pow(10, decimals);
return Math.round(val * factor) / factor;
}
function coordKey(lat: number, lng: number): string {
return `${roundCoord(lat)}:${roundCoord(lng)}`;
}
function areaSqKm(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number {
// Approximate area in sq km using lat/lng → meters
const latMeters = (bounds.maxLat - bounds.minLat) * 111320;
const avgLat = (bounds.minLat + bounds.maxLat) / 2;
const lngMeters = (bounds.maxLng - bounds.minLng) * 111320 * Math.cos((avgLat * Math.PI) / 180);
return (latMeters * lngMeters) / 1_000_000;
}
async function writeProgress(importId: string, progress: AreaImportProgress): Promise<void> {
try {
await redis.set(
`${PROGRESS_KEY_PREFIX}${importId}`,
JSON.stringify(progress),
'EX',
PROGRESS_TTL,
);
} catch (err) {
logger.warn('Failed to write area import progress to Redis', err);
}
}
/**
* Resolve bounding box from area type options.
*/
async function resolveBounds(
options: AreaImportPreviewInput | AreaImportStartInput,
): Promise<{
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
cutPolygons?: number[][][];
}> {
if (options.areaType === 'cut' && options.cutId) {
const cut = await prisma.cut.findUnique({ where: { id: options.cutId } });
if (!cut) throw new Error('Cut not found');
const polygons = parseGeoJsonPolygon(cut.geojson);
// Flatten all polygon coordinates for bounds calculation
const allCoords = polygons.flat();
const bounds = calculateBounds(allCoords);
return { bounds, cutPolygons: polygons };
}
if (options.areaType === 'viewport' && options.center && options.zoom !== undefined) {
const bounds = boundsFromCenterZoom(
options.center.lat,
options.center.lng,
options.zoom,
options.viewportWidth,
options.viewportHeight,
);
return { bounds };
}
throw new Error('Invalid area type configuration');
}
/**
* Load existing location coordinates within bounds for deduplication.
*/
async function loadExistingCoords(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): Promise<Set<string>> {
const existing = await prisma.location.findMany({
where: {
latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
},
select: { latitude: true, longitude: true },
});
const coords = new Set<string>();
for (const loc of existing) {
coords.add(coordKey(Number(loc.latitude), Number(loc.longitude)));
}
return coords;
}
/**
* Rough province bounding boxes for auto-detecting which NAR datasets
* might overlap with the import area.
*/
const PROVINCE_BOUNDS: Record<string, { minLat: number; maxLat: number; minLng: number; maxLng: number }> = {
'10': { minLat: 46.6, maxLat: 60.4, minLng: -67.8, maxLng: -52.6 }, // NL
'11': { minLat: 45.9, maxLat: 47.1, minLng: -64.4, maxLng: -62.0 }, // PE
'12': { minLat: 43.4, maxLat: 47.0, minLng: -66.4, maxLng: -59.7 }, // NS
'13': { minLat: 44.6, maxLat: 48.1, minLng: -69.1, maxLng: -63.8 }, // NB
'24': { minLat: 45.0, maxLat: 62.6, minLng: -79.8, maxLng: -57.1 }, // QC
'35': { minLat: 41.7, maxLat: 56.9, minLng: -95.2, maxLng: -74.3 }, // ON
'46': { minLat: 49.0, maxLat: 60.0, minLng: -102.0, maxLng: -88.9 }, // MB
'47': { minLat: 49.0, maxLat: 60.0, minLng: -110.0, maxLng: -101.4 }, // SK
'48': { minLat: 49.0, maxLat: 60.0, minLng: -120.0, maxLng: -110.0 }, // AB
'59': { minLat: 48.3, maxLat: 60.0, minLng: -139.1, maxLng: -114.1 }, // BC
'60': { minLat: 60.0, maxLat: 69.6, minLng: -141.0, maxLng: -124.0 }, // YT
'61': { minLat: 60.0, maxLat: 78.8, minLng: -136.5, maxLng: -102.0 }, // NT
'62': { minLat: 51.7, maxLat: 83.1, minLng: -120.4, maxLng: -61.2 }, // NU
};
function detectOverlappingProvinces(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): string[] {
const overlapping: string[] = [];
for (const [code, pb] of Object.entries(PROVINCE_BOUNDS)) {
const overlaps = bounds.minLat <= pb.maxLat && bounds.maxLat >= pb.minLat &&
bounds.minLng <= pb.maxLng && bounds.maxLng >= pb.minLng;
if (overlaps) overlapping.push(code);
}
return overlapping;
}
/**
* Generate grid points within bounds for reverse geocoding.
*/
function generateGridPoints(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
spacingMeters: number,
maxPoints: number,
cutPolygons?: number[][][],
): { lat: number; lng: number }[] {
const avgLat = (bounds.minLat + bounds.maxLat) / 2;
const latStep = spacingMeters / 111320;
const lngStep = spacingMeters / (111320 * Math.cos((avgLat * Math.PI) / 180));
const points: { lat: number; lng: number }[] = [];
for (let lat = bounds.minLat; lat <= bounds.maxLat; lat += latStep) {
for (let lng = bounds.minLng; lng <= bounds.maxLng; lng += lngStep) {
if (points.length >= maxPoints) break;
// If cut polygon provided, only include points inside it
if (cutPolygons && cutPolygons.length > 0) {
const inside = cutPolygons.some((ring) => isPointInPolygon(lat, lng, ring));
if (!inside) continue;
}
points.push({ lat, lng });
}
if (points.length >= maxPoints) break;
}
return points;
}
// ---- Main Service ----
export const areaImportService = {
/**
* Preview area import: returns bounds, area, estimates, and existing count.
*/
async previewAreaImport(options: AreaImportPreviewInput): Promise<AreaImportPreviewResult> {
const { bounds, cutPolygons } = await resolveBounds(options);
const area = areaSqKm(bounds);
// Count existing locations in bounds
const existingCount = await prisma.location.count({
where: {
latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
},
});
const estimates = { osm: 0, nar: 0, reverseGeocode: 0 };
const narProvincesDetected: string[] = [];
// OSM estimate
if (options.sources.osm) {
const count = await overpassService.estimateCount(bounds);
estimates.osm = count >= 0 ? count : -1;
}
// NAR estimate: detect provinces, count total address file sizes as rough guide
if (options.sources.nar) {
const overlapping = detectOverlappingProvinces(bounds);
narProvincesDetected.push(...overlapping);
if (overlapping.length > 0) {
const { datasets } = await narImportService.listDatasets();
for (const code of overlapping) {
const ds = datasets.find((d) => d.provinceCode === code);
if (ds && ds.addressFiles.length > 0) {
// Very rough estimate: ~50 bytes per address row, filtered by area ratio
const provinceArea = areaSqKm(PROVINCE_BOUNDS[code]!);
const overlapRatio = Math.min(1, area / provinceArea);
const totalRows = ds.totalAddressSize / 50;
estimates.nar += Math.round(totalRows * overlapRatio);
}
}
}
}
// Reverse geocode estimate: number of grid points
if (options.sources.reverseGeocode) {
const rgConfig = typeof options.sources.reverseGeocode === 'object'
? options.sources.reverseGeocode
: { gridSpacingMeters: 100, maxPoints: env.AREA_IMPORT_MAX_GRID_POINTS };
const points = generateGridPoints(bounds, rgConfig.gridSpacingMeters, rgConfig.maxPoints, cutPolygons);
estimates.reverseGeocode = points.length;
}
return { bounds, areaSqKm: area, existingLocations: existingCount, estimates, narProvincesDetected };
},
/**
* Get import progress from Redis.
*/
async getProgress(importId: string): Promise<AreaImportProgress | null> {
const data = await redis.get(`${PROGRESS_KEY_PREFIX}${importId}`);
if (!data) return null;
return JSON.parse(data) as AreaImportProgress;
},
/**
* Run the full area import (fire-and-forget).
*/
async runAreaImport(
userId: string,
options: AreaImportStartInput,
importId: string,
): Promise<void> {
const progress: AreaImportProgress = {
status: 'initializing',
bounds: null,
areaSqKm: 0,
sources: {
osm: { status: 'pending', candidatesFound: 0 },
nar: { status: 'pending', candidatesFound: 0 },
reverseGeocode: { status: 'pending', candidatesFound: 0 },
},
locationsCreated: 0,
addressesCreated: 0,
skippedDuplicate: 0,
totalCandidates: 0,
};
const updateProgress = async (updates?: Partial<AreaImportProgress>) => {
if (updates) Object.assign(progress, updates);
await writeProgress(importId, progress);
};
try {
// 1. Resolve bounds
const { bounds, cutPolygons } = await resolveBounds(options);
const area = areaSqKm(bounds);
await updateProgress({ bounds, areaSqKm: area, status: 'running' });
// 2. Load existing coords for dedup
const existingCoords = options.deduplicateRadius > 0
? await loadExistingCoords(bounds)
: new Set<string>();
// 3. Run enabled sources in parallel (OSM=network, NAR=disk — they don't compete)
const allCandidates: CandidateLocation[] = [];
// Mark skipped sources
if (!options.sources.osm) progress.sources.osm.status = 'skipped';
if (!options.sources.nar) progress.sources.nar.status = 'skipped';
if (!options.sources.reverseGeocode) progress.sources.reverseGeocode.status = 'skipped';
await updateProgress();
const sourcePromises: Promise<void>[] = [];
// --- OSM Source ---
if (options.sources.osm) {
sourcePromises.push((async () => {
progress.sources.osm.status = 'running';
await updateProgress();
try {
const osmCandidates = await overpassService.queryArea(bounds, (msg) => {
progress.sources.osm.message = msg;
writeProgress(importId, progress).catch(() => {});
});
// Filter by cut polygon if applicable
let filtered = osmCandidates;
if (cutPolygons && cutPolygons.length > 0) {
filtered = osmCandidates.filter((c) =>
cutPolygons.some((ring) => isPointInPolygon(c.latitude, c.longitude, ring)),
);
}
allCandidates.push(...filtered);
progress.sources.osm.status = 'complete';
progress.sources.osm.candidatesFound = filtered.length;
await updateProgress();
logger.info(`OSM source: ${filtered.length} candidates (${osmCandidates.length} pre-filter)`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.sources.osm.status = 'failed';
progress.sources.osm.error = msg;
await updateProgress();
logger.error(`OSM source failed: ${msg}`);
}
})());
}
// --- NAR Source ---
if (options.sources.nar) {
sourcePromises.push((async () => {
progress.sources.nar.status = 'running';
progress.sources.nar.message = 'Detecting provinces...';
await updateProgress();
try {
const overlapping = detectOverlappingProvinces(bounds);
if (overlapping.length === 0) {
progress.sources.nar.status = 'complete';
progress.sources.nar.message = 'No NAR data for this area';
await updateProgress();
return;
}
const { datasets } = await narImportService.listDatasets();
const narCandidates: CandidateLocation[] = [];
const narConfig = typeof options.sources.nar === 'object' ? options.sources.nar : { residentialOnly: true };
for (const code of overlapping) {
const dataset = datasets.find((d) => d.provinceCode === code);
if (!dataset || dataset.addressFiles.length === 0) continue;
progress.sources.nar.message = `Processing ${dataset.provinceName}...`;
await updateProgress();
// Load location lookup for this province
const locationLookup = dataset.locationFiles.length > 0
? await narImportService.loadLocationLookup(dataset.locationFiles)
: new Map<string, { lat: number; lng: number; fedDistrict?: string }>();
// Stream address files and filter by bounds
for (const addressFile of dataset.addressFiles) {
const { parse } = await import('csv-parse');
const fs = await import('fs');
const parser = fs.createReadStream(addressFile.fullPath).pipe(
parse({ columns: true, skip_empty_lines: true, trim: true, bom: true }),
);
for await (const record of parser) {
const locGuid = (record.LOC_GUID ?? '').trim();
if (!locGuid) continue;
// Residential filter
const buUse = parseInt(record.BU_USE ?? '', 10);
if (narConfig.residentialOnly && buUse === 3) continue;
// Get coordinates
let lat: number | undefined;
let lng: number | undefined;
let federalDistrict: string | undefined;
const locData = locationLookup.get(locGuid);
if (locData) {
lat = locData.lat;
lng = locData.lng;
federalDistrict = locData.fedDistrict;
}
if (lat === undefined || lng === undefined) continue;
// Bounds filter
if (lat < bounds.minLat || lat > bounds.maxLat || lng < bounds.minLng || lng > bounds.maxLng) continue;
// Cut polygon filter
if (cutPolygons && cutPolygons.length > 0) {
const inside = cutPolygons.some((ring) => isPointInPolygon(lat!, lng!, ring));
if (!inside) continue;
}
// Build address string
const civicNo = (record.CIVIC_NO ?? '').trim();
const civicSuffix = (record.CIVIC_NO_SUFFIX ?? '').trim();
const streetName = (record.OFFICIAL_STREET_NAME ?? '').trim();
const streetType = (record.OFFICIAL_STREET_TYPE ?? '').trim();
const streetDir = (record.OFFICIAL_STREET_DIR ?? '').trim();
const city = (record.MAIL_MUN_NAME ?? record.CSD_ENG_NAME ?? '').trim();
const prov = (record.MAIL_PROV_ABVN ?? '').trim();
const postalCode = (record.MAIL_POSTAL_CODE ?? '').trim() || undefined;
if (!streetName) continue;
const streetParts = [
civicNo + (civicSuffix || ''),
streetName,
streetType,
streetDir,
].filter(Boolean);
let address = streetParts.join(' ');
if (city) address += `, ${city}`;
if (prov) address += `, ${prov}`;
narCandidates.push({
latitude: lat,
longitude: lng,
address,
postalCode,
city: city || undefined,
province: prov || undefined,
source: 'nar',
confidence: 90,
priority: 3,
});
}
}
}
allCandidates.push(...narCandidates);
progress.sources.nar.status = 'complete';
progress.sources.nar.candidatesFound = narCandidates.length;
await updateProgress();
logger.info(`NAR source: ${narCandidates.length} candidates`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.sources.nar.status = 'failed';
progress.sources.nar.error = msg;
await updateProgress();
logger.error(`NAR source failed: ${msg}`);
}
})());
}
// Wait for OSM and NAR to finish before starting reverse geocode
await Promise.all(sourcePromises);
// --- Reverse Geocode Source (runs after OSM/NAR for better dedup) ---
if (options.sources.reverseGeocode) {
progress.sources.reverseGeocode.status = 'running';
await updateProgress();
try {
const rgConfig = typeof options.sources.reverseGeocode === 'object'
? options.sources.reverseGeocode
: { gridSpacingMeters: 100, maxPoints: env.AREA_IMPORT_MAX_GRID_POINTS };
const gridPoints = generateGridPoints(
bounds,
rgConfig.gridSpacingMeters,
rgConfig.maxPoints,
cutPolygons,
);
// Build set of already-discovered locations for proximity skip
const discoveredCoords = new Set<string>();
for (const c of allCandidates) {
discoveredCoords.add(coordKey(c.latitude, c.longitude));
}
const rgCandidates: CandidateLocation[] = [];
let processed = 0;
for (const point of gridPoints) {
// Skip points near already-discovered locations
const pk = coordKey(point.lat, point.lng);
if (discoveredCoords.has(pk) || existingCoords.has(pk)) {
processed++;
continue;
}
// Also check proximity (30m) to any discovered candidate
let tooClose = false;
for (const c of allCandidates) {
if (haversineDistance(point.lat, point.lng, c.latitude, c.longitude) < 30) {
tooClose = true;
break;
}
}
if (tooClose) {
processed++;
continue;
}
try {
const result = await geocodingService.reverseGeocode(point.lat, point.lng);
if (result && result.address) {
const candidate: CandidateLocation = {
latitude: point.lat,
longitude: point.lng,
address: result.address,
city: result.city,
province: result.province,
source: 'reverse-geocode',
confidence: 40,
priority: 1,
};
rgCandidates.push(candidate);
discoveredCoords.add(pk);
}
} catch {
// Skip failed reverse geocode points
}
processed++;
if (processed % 10 === 0) {
progress.sources.reverseGeocode.message = `${processed}/${gridPoints.length} grid points`;
progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
await updateProgress();
}
// Nominatim rate limit: ~1 request/second
await new Promise((resolve) => setTimeout(resolve, 1100));
}
allCandidates.push(...rgCandidates);
progress.sources.reverseGeocode.status = 'complete';
progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
await updateProgress();
logger.info(`Reverse geocode source: ${rgCandidates.length} candidates from ${gridPoints.length} grid points`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.sources.reverseGeocode.status = 'failed';
progress.sources.reverseGeocode.error = msg;
await updateProgress();
logger.error(`Reverse geocode source failed: ${msg}`);
}
}
// 4. Cross-source dedup: coordinate hash, priority ordering (NAR > OSM > ReverseGeocode)
progress.totalCandidates = allCandidates.length;
progress.status = 'creating-records';
await updateProgress();
// Sort by priority descending so highest-priority source wins on duplicate coords
allCandidates.sort((a, b) => b.priority - a.priority);
const dedupMap = new Map<string, CandidateLocation>();
let skippedDuplicate = 0;
for (const candidate of allCandidates) {
const key = coordKey(candidate.latitude, candidate.longitude);
// Skip if already exists in DB
if (existingCoords.has(key)) {
skippedDuplicate++;
continue;
}
// Cross-source dedup: first (highest priority) wins
if (!dedupMap.has(key)) {
dedupMap.set(key, candidate);
} else {
skippedDuplicate++;
}
}
progress.skippedDuplicate = skippedDuplicate;
await updateProgress();
// 5. Batch create Location + Address records
const uniqueCandidates = Array.from(dedupMap.values());
logger.info(`Creating ${uniqueCandidates.length} locations (${skippedDuplicate} duplicates skipped)`);
const batchSize = ('batchSize' in options) ? options.batchSize : 1000;
let locationsCreated = 0;
let addressesCreated = 0;
for (let i = 0; i < uniqueCandidates.length; i += batchSize) {
const batch = uniqueCandidates.slice(i, i + batchSize);
const locationBatch: Prisma.LocationCreateManyInput[] = [];
const addressBatch: Prisma.AddressCreateManyInput[] = [];
for (const c of batch) {
const locationId = `loc_${Date.now()}_${Math.random().toString(36).substring(7)}`;
locationBatch.push({
id: locationId,
latitude: c.latitude,
longitude: c.longitude,
address: c.address,
postalCode: c.postalCode,
province: c.province,
geocodeConfidence: c.confidence,
geocodeProvider: c.source === 'osm' ? 'NOMINATIM' : c.source === 'nar' ? 'UNKNOWN' : 'NOMINATIM',
buildingType: 'SINGLE_FAMILY',
totalUnits: 1,
createdByUserId: userId,
});
addressBatch.push({
id: `addr_${Date.now()}_${Math.random().toString(36).substring(7)}`,
locationId,
createdByUserId: userId,
});
}
await prisma.location.createMany({ data: locationBatch, skipDuplicates: true });
await prisma.address.createMany({ data: addressBatch, skipDuplicates: true });
locationsCreated += locationBatch.length;
addressesCreated += addressBatch.length;
progress.locationsCreated = locationsCreated;
progress.addressesCreated = addressesCreated;
await updateProgress();
}
progress.status = 'complete';
await updateProgress();
logger.info(`Area import ${importId} complete: ${locationsCreated} locations, ${addressesCreated} addresses`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.status = 'failed';
progress.error = msg;
await updateProgress();
logger.error(`Area import ${importId} failed: ${msg}`);
}
},
};

View File

@@ -0,0 +1,211 @@
import { env } from '../../../config/env';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
export interface CandidateLocation {
latitude: number;
longitude: number;
address: string;
postalCode?: string;
city?: string;
province?: string;
source: 'osm' | 'nar' | 'reverse-geocode';
confidence: number;
priority: number; // NAR=3, OSM=2, ReverseGeocode=1
}
interface OverpassElement {
type: 'node' | 'way' | 'relation';
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
interface OverpassResponse {
elements: OverpassElement[];
}
interface OverpassCountResponse {
elements: { tags: { total: string } }[];
}
const REDIS_LAST_REQUEST_KEY = 'overpass:last-request';
const MAX_AREA_SQ_DEG = 0.05; // ~25 sq km — split above this
/**
* Enforce minimum delay between Overpass API requests.
* Uses Redis timestamp to coordinate across potential instances.
*/
async function waitForRateLimit(): Promise<void> {
const minDelay = env.OVERPASS_MIN_DELAY_MS;
const now = Date.now();
const lastStr = await redis.get(REDIS_LAST_REQUEST_KEY);
const lastRequest = lastStr ? parseInt(lastStr, 10) : 0;
const elapsed = now - lastRequest;
if (elapsed < minDelay) {
const waitMs = minDelay - elapsed;
logger.debug(`Overpass rate limit: waiting ${waitMs}ms`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
await redis.set(REDIS_LAST_REQUEST_KEY, Date.now().toString(), 'EX', 120);
}
/**
* Execute an Overpass API query.
*/
async function queryOverpass<T>(query: string): Promise<T> {
await waitForRateLimit();
const url = env.OVERPASS_API_URL;
logger.info(`Overpass query to ${url} (${query.length} chars)`);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal: AbortSignal.timeout(200000), // 200s timeout (Overpass can be slow)
});
if (response.status === 429) {
throw new Error('Overpass API rate limit exceeded. Try again later or use a private instance.');
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Overpass API error ${response.status}: ${text.substring(0, 200)}`);
}
return response.json() as Promise<T>;
}
/**
* Split a bounding box into 4 quadrants.
*/
function splitBounds(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
const midLat = (bounds.minLat + bounds.maxLat) / 2;
const midLng = (bounds.minLng + bounds.maxLng) / 2;
return [
{ minLat: bounds.minLat, maxLat: midLat, minLng: bounds.minLng, maxLng: midLng },
{ minLat: bounds.minLat, maxLat: midLat, minLng: midLng, maxLng: bounds.maxLng },
{ minLat: midLat, maxLat: bounds.maxLat, minLng: bounds.minLng, maxLng: midLng },
{ minLat: midLat, maxLat: bounds.maxLat, minLng: midLng, maxLng: bounds.maxLng },
];
}
function areaSqDeg(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number {
return (bounds.maxLat - bounds.minLat) * (bounds.maxLng - bounds.minLng);
}
/**
* Build address string from OSM tags.
*/
function buildAddress(tags: Record<string, string>): string {
const parts: string[] = [];
const houseNumber = tags['addr:housenumber'];
const street = tags['addr:street'];
if (houseNumber) parts.push(houseNumber);
if (street) parts.push(street);
const city = tags['addr:city'];
if (city) parts.push(city);
const province = tags['addr:province'] || tags['addr:state'];
if (province) parts.push(province);
return parts.join(', ') || `${houseNumber || '?'} ${street || 'Unknown Street'}`;
}
/**
* Parse Overpass elements into CandidateLocation objects.
*/
function parseElements(elements: OverpassElement[]): CandidateLocation[] {
const candidates: CandidateLocation[] = [];
for (const el of elements) {
const lat = el.lat ?? el.center?.lat;
const lon = el.lon ?? el.center?.lon;
if (!lat || !lon) continue;
const tags = el.tags ?? {};
if (!tags['addr:housenumber']) continue;
candidates.push({
latitude: lat,
longitude: lon,
address: buildAddress(tags),
postalCode: tags['addr:postcode'],
city: tags['addr:city'],
province: tags['addr:province'] || tags['addr:state'],
source: 'osm',
confidence: 70,
priority: 2,
});
}
return candidates;
}
export const overpassService = {
/**
* Estimate the number of address nodes in a bounding box using [out:count].
*/
async estimateCount(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): Promise<number> {
const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`;
const query = `[out:json][timeout:60];(node["addr:housenumber"](${bbox});way["building"]["addr:housenumber"](${bbox}););out count;`;
try {
const data = await queryOverpass<OverpassCountResponse>(query);
const totalStr = data.elements?.[0]?.tags?.total;
return totalStr ? parseInt(totalStr, 10) : 0;
} catch (err) {
logger.warn('Overpass count estimate failed:', err);
return -1; // -1 indicates unknown
}
},
/**
* Query all address data within a bounding box.
* Automatically splits large areas into sub-quadrants.
*/
async queryArea(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
onProgress?: (msg: string) => void,
): Promise<CandidateLocation[]> {
const area = areaSqDeg(bounds);
// If area is too large, split into quadrants and query each
if (area > MAX_AREA_SQ_DEG) {
const quadrants = splitBounds(bounds);
const allCandidates: CandidateLocation[] = [];
const totalQuadrants = quadrants.length;
for (let i = 0; i < totalQuadrants; i++) {
onProgress?.(`Querying OSM quadrant ${i + 1}/${totalQuadrants}`);
try {
const subCandidates = await this.queryArea(quadrants[i]!, onProgress);
allCandidates.push(...subCandidates);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`Overpass quadrant ${i + 1} failed: ${msg}`);
// Continue with other quadrants
}
}
return allCandidates;
}
const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`;
const query = `[out:json][timeout:180];(node["addr:housenumber"](${bbox});way["building"]["addr:housenumber"](${bbox}););out center body;`;
onProgress?.('Querying OSM addresses...');
const data = await queryOverpass<OverpassResponse>(query);
return parseElements(data.elements);
},
};

View File

@@ -3,7 +3,6 @@ import { Prisma, ShiftStatus, SignupStatus, SignupSource } from '@prisma/client'
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { emailService } from '../../../services/email.service';
import { siteSettingsService } from '../../settings/settings.service';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { recordShiftSignup } from '../../../utils/metrics';
@@ -326,6 +325,7 @@ export const shiftsService = {
name: data.name,
phone: data.phone,
role: 'TEMP',
roles: JSON.parse(JSON.stringify(['TEMP'])),
createdVia: 'PUBLIC_SHIFT_SIGNUP',
expiresAt: shiftDate,
},
@@ -388,32 +388,16 @@ export const shiftsService = {
day: 'numeric',
});
const htmlTemplate = emailService.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = emailService.loadTemplate('shift-signup-confirmation', 'txt');
let orgName = 'Changemaker Lite';
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ }
const vars: Record<string, string> = {
USER_NAME: data.name,
USER_EMAIL: data.email,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
SHIFT_LOCATION: shift.location || 'TBD',
IS_NEW_USER: isNewUser ? 'true' : '',
TEMP_PASSWORD: tempPassword || '',
LOGIN_URL: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
ORGANIZATION_NAME: orgName,
};
const html = emailService.processTemplate(htmlTemplate, vars);
const text = emailService.processTemplate(txtTemplate, vars);
await emailService.sendEmail({
to: data.email,
subject: `Signup Confirmed — ${shift.title}`,
html,
text,
await emailService.sendShiftSignupConfirmation({
recipientEmail: data.email,
recipientName: data.name,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
isNewUser,
tempPassword,
loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
});
} catch (err) {
logger.error('Failed to send shift signup confirmation email:', err);
@@ -561,32 +545,16 @@ export const shiftsService = {
const dateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
const htmlTemplate = emailService.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = emailService.loadTemplate('shift-signup-confirmation', 'txt');
let orgName = 'Changemaker Lite';
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* default */ }
const vars: Record<string, string> = {
USER_NAME: user.name || user.email,
USER_EMAIL: user.email,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
SHIFT_LOCATION: shift.location || 'TBD',
IS_NEW_USER: '',
TEMP_PASSWORD: '',
LOGIN_URL: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
ORGANIZATION_NAME: orgName,
};
const html = emailService.processTemplate(htmlTemplate, vars);
const text = emailService.processTemplate(txtTemplate, vars);
await emailService.sendEmail({
to: user.email,
subject: `Signup Confirmed — ${shift.title}`,
html,
text,
await emailService.sendShiftSignupConfirmation({
recipientEmail: user.email,
recipientName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
isNewUser: false,
loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
});
} catch (err) {
logger.error('Failed to send volunteer shift signup confirmation email:', err);
@@ -703,38 +671,23 @@ export const shiftsService = {
day: 'numeric',
});
const htmlTemplate = emailService.loadTemplate('shift-details', 'html');
const txtTemplate = emailService.loadTemplate('shift-details', 'txt');
let orgName = 'Changemaker Lite';
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ }
let sent = 0;
let failed = 0;
for (const signup of shift.signups) {
try {
const vars: Record<string, string> = {
USER_NAME: signup.userName || signup.userEmail,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_START_TIME: shift.startTime,
SHIFT_END_TIME: shift.endTime,
SHIFT_LOCATION: shift.location || 'TBD',
SHIFT_DESCRIPTION: shift.description || '',
CURRENT_VOLUNTEERS: shift.currentVolunteers.toString(),
MAX_VOLUNTEERS: shift.maxVolunteers.toString(),
SHIFT_STATUS: shift.status,
ORGANIZATION_NAME: orgName,
};
const html = emailService.processTemplate(htmlTemplate, vars);
const text = emailService.processTemplate(txtTemplate, vars);
const result = await emailService.sendEmail({
to: signup.userEmail,
subject: `Shift Details — ${shift.title}`,
html,
text,
const result = await emailService.sendShiftDetailsEmail({
recipientEmail: signup.userEmail,
recipientName: signup.userName || signup.userEmail,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
});
if (result.success) {

View File

@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { hasAnyRole, ADMIN_ROLES as ADMIN_ROLE_LIST, getUserRoles } from '../../../utils/roles';
// Extend FastifyRequest to include user
declare module 'fastify' {
@@ -11,6 +12,7 @@ declare module 'fastify' {
id: string;
email: string;
role: UserRole;
roles: UserRole[];
};
}
}
@@ -19,6 +21,7 @@ interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
/**
@@ -58,6 +61,7 @@ export async function authenticate(
id: true,
email: true,
role: true,
roles: true,
status: true,
expiresAt: true,
},
@@ -86,10 +90,12 @@ export async function authenticate(
}
// Attach user to request
const userRoles = getUserRoles(user);
request.user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
roles: userRoles,
};
}
@@ -109,9 +115,8 @@ export async function requireAdminRole(
return;
}
// Check admin role (allow all admin roles)
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
if (!request.user || !ADMIN_ROLES.includes(request.user.role)) {
// Check admin role using multi-role utility
if (!request.user || !hasAnyRole(request.user, ADMIN_ROLE_LIST)) {
return reply.status(403).send({
error: 'Admin access required',
code: 'ADMIN_REQUIRED'
@@ -145,15 +150,18 @@ export async function optionalAuth(
id: true,
email: true,
role: true,
roles: true,
status: true,
},
});
if (user && user.status === UserStatus.ACTIVE) {
const userRoles = getUserRoles(user);
request.user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
roles: userRoles,
};
}
} catch {

View File

@@ -0,0 +1,117 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
import { env } from '../../../config/env';
import { UserRole } from '@prisma/client';
/**
* Chat Notifications SSE Routes
*
* Provides per-user SSE streams for real-time chat reply notifications.
* Since EventSource can't send Authorization headers, JWT is passed as query param.
*/
interface TokenPayload {
id: string;
email: string;
role: UserRole;
}
// In-memory subscriber map: userId → Set of SSE writers
const subscribers = new Map<string, Set<(data: string) => void>>();
/**
* Notify a user of a chat reply via SSE
*/
export function notifyUser(userId: string, notification: {
type: 'chat_reply';
videoId: number;
videoTitle: string;
commentId: number;
commenterName: string;
contentPreview: string;
}): void {
const writers = subscribers.get(userId);
if (!writers || writers.size === 0) return;
const data = JSON.stringify(notification);
for (const write of writers) {
try {
write(data);
} catch {
// Writer disconnected, will be cleaned up
}
}
}
export async function chatNotificationsRoutes(fastify: FastifyInstance) {
/**
* GET /notifications/stream?token=JWT
* Per-user SSE stream for chat reply notifications
*/
fastify.get(
'/notifications/stream',
async (
request: FastifyRequest<{ Querystring: { token?: string } }>,
reply: FastifyReply
) => {
const token = request.query.token;
if (!token) {
return reply.code(401).send({ message: 'Authentication token required' });
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
} catch {
return reply.code(401).send({ message: 'Invalid or expired token' });
}
const userId = payload.id;
// Set SSE headers
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Writer function for this connection
const writer = (data: string) => {
reply.raw.write(`data: ${data}\n\n`);
};
// Register subscriber
if (!subscribers.has(userId)) {
subscribers.set(userId, new Set());
}
subscribers.get(userId)!.add(writer);
// Send connection confirmation
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`);
// Keep-alive ping every 30 seconds
const pingInterval = setInterval(() => {
try {
reply.raw.write(': ping\n\n');
} catch {
// Connection closed
}
}, 30000);
// Cleanup on disconnect
request.raw.on('close', () => {
clearInterval(pingInterval);
const writers = subscribers.get(userId);
if (writers) {
writers.delete(writer);
if (writers.size === 0) {
subscribers.delete(userId);
}
}
});
}
);
}

View File

@@ -25,7 +25,7 @@ export async function chatStreamRoutes(fastify: FastifyInstance) {
* SSE endpoint for real-time chat updates
*/
fastify.get(
'/public/:id/stream',
'/public/:id/chat-stream',
async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply

View File

@@ -0,0 +1,161 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { authenticate } from '../middleware/auth';
interface ThreadsQuery {
limit?: string;
offset?: string;
}
export async function chatThreadsRoutes(fastify: FastifyInstance) {
// All routes require authentication
fastify.addHook('preHandler', authenticate);
/**
* GET /chat/threads
* List videos where the authenticated user has commented,
* ordered by latest activity, with unread counts.
*/
fastify.get(
'/chat/threads',
async (
request: FastifyRequest<{ Querystring: ThreadsQuery }>,
reply
) => {
try {
const userId = request.user!.id;
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
const offset = parseInt(request.query.offset || '0', 10);
// Find distinct video IDs where user has commented
const userVideoIds = await prisma.comment.findMany({
where: { userId },
select: { mediaId: true },
distinct: ['mediaId'],
});
if (userVideoIds.length === 0) {
return reply.send({ threads: [], total: 0 });
}
const mediaIds = userVideoIds.map((c) => c.mediaId);
// Get the user's read statuses
const readStatuses = await prisma.chatThreadReadStatus.findMany({
where: { userId, mediaId: { in: mediaIds } },
});
const readStatusMap = new Map(readStatuses.map((r) => [r.mediaId, r.lastSeenAt]));
// For each video, get latest comment and unread count
const threads = await Promise.all(
mediaIds.map(async (mediaId) => {
const lastSeenAt = readStatusMap.get(mediaId);
const [latestComment, totalComments, unreadCount, video] = await Promise.all([
prisma.comment.findFirst({
where: { mediaId, isHidden: { not: true } },
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
},
}),
prisma.comment.count({
where: { mediaId, isHidden: { not: true } },
}),
lastSeenAt
? prisma.comment.count({
where: {
mediaId,
isHidden: { not: true },
createdAt: { gt: lastSeenAt },
},
})
: prisma.comment.count({
where: { mediaId, isHidden: { not: true } },
}),
prisma.video.findUnique({
where: { id: mediaId },
select: { id: true, filename: true, thumbnailPath: true },
}),
]);
return {
mediaId,
videoTitle: video?.filename || `Video #${mediaId}`,
thumbnailPath: video?.thumbnailPath || null,
totalComments,
unreadCount,
lastActivity: latestComment?.createdAt.toISOString() || null,
lastMessage: latestComment
? {
content: latestComment.content.length > 100
? latestComment.content.substring(0, 100) + '...'
: latestComment.content,
userName: latestComment.user?.name || latestComment.user?.email || 'Anonymous',
createdAt: latestComment.createdAt.toISOString(),
}
: null,
};
})
);
// Sort by lastActivity descending
threads.sort((a, b) => {
if (!a.lastActivity) return 1;
if (!b.lastActivity) return -1;
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
});
// Paginate
const paginated = threads.slice(offset, offset + limit);
return reply.send({ threads: paginated, total: threads.length });
} catch (error) {
console.error('Failed to fetch chat threads:', error);
return reply.code(500).send({ message: 'Failed to fetch chat threads' });
}
}
);
/**
* POST /chat/threads/:mediaId/read
* Upsert ChatThreadReadStatus (lastSeenAt: now())
*/
fastify.post(
'/chat/threads/:mediaId/read',
async (
request: FastifyRequest<{ Params: { mediaId: string } }>,
reply
) => {
try {
const userId = request.user!.id;
const mediaId = parseInt(request.params.mediaId, 10);
if (isNaN(mediaId)) {
return reply.code(400).send({ message: 'Invalid media ID' });
}
// Find existing read status by unique index
const existing = await prisma.chatThreadReadStatus.findFirst({
where: { userId, mediaId },
});
if (existing) {
await prisma.chatThreadReadStatus.update({
where: { id: existing.id },
data: { lastSeenAt: new Date() },
});
} else {
await prisma.chatThreadReadStatus.create({
data: { userId, mediaId, lastSeenAt: new Date() },
});
}
return reply.send({ message: 'Thread marked as read' });
} catch (error) {
console.error('Failed to mark thread as read:', error);
return reply.code(500).send({ message: 'Failed to mark thread as read' });
}
}
);
}

View File

@@ -0,0 +1,505 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { invalidateWordListCache } from '../services/word-filter.service';
interface ListCommentsQuery {
page?: string;
limit?: string;
status?: string; // 'pending' | 'safe' | 'flagged' | 'hidden'
videoId?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
}
interface CommentIdParams {
id: string;
}
interface HideCommentBody {
reason?: 'manual' | 'word_filter' | 'spam' | 'link';
}
interface UpdateNotesBody {
notes: string;
}
interface AddWordBody {
word: string;
level: 'low' | 'medium' | 'high' | 'custom';
}
interface WordFilterIdParams {
id: string;
}
export async function commentAdminRoutes(fastify: FastifyInstance) {
// All routes require admin role
fastify.addHook('preHandler', requireAdminRole);
/**
* GET /admin/comments/stats
* Counts by status (pending/flagged/hidden/total)
*/
fastify.get(
'/admin/comments/stats',
async (_request, reply) => {
try {
const [total, pending, flagged, hidden, safe] = await Promise.all([
prisma.comment.count(),
prisma.comment.count({ where: { safetyStatus: 'pending' } }),
prisma.comment.count({ where: { safetyStatus: 'flagged' } }),
prisma.comment.count({ where: { isHidden: true } }),
prisma.comment.count({ where: { safetyStatus: 'safe' } }),
]);
return reply.send({ total, pending, flagged, hidden, safe });
} catch (error) {
console.error('Failed to fetch comment stats:', error);
return reply.code(500).send({ message: 'Failed to fetch comment stats' });
}
}
);
/**
* GET /admin/comments
* List all comments with filters, pagination, includes user + video title
*/
fastify.get(
'/admin/comments',
async (
request: FastifyRequest<{ Querystring: ListCommentsQuery }>,
reply
) => {
try {
const page = parseInt(request.query.page || '1', 10);
const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
const skip = (page - 1) * limit;
const { status, videoId, search, dateFrom, dateTo } = request.query;
// Build where clause
const where: any = {};
if (status === 'hidden') {
where.isHidden = true;
} else if (status === 'pending' || status === 'safe' || status === 'flagged') {
where.safetyStatus = status;
where.isHidden = { not: true };
}
if (videoId) {
const vid = parseInt(videoId, 10);
if (!isNaN(vid)) where.mediaId = vid;
}
if (search) {
where.content = { contains: search, mode: 'insensitive' };
}
if (dateFrom || dateTo) {
where.createdAt = {};
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
if (dateTo) where.createdAt.lte = new Date(dateTo);
}
const [comments, total] = await Promise.all([
prisma.comment.findMany({
where,
include: {
user: { select: { id: true, email: true, name: true } },
media: { select: { id: true, filename: true } },
moderation: {
select: {
id: true,
status: true,
moderatedAt: true,
reason: true,
moderator: { select: { id: true, name: true } },
},
},
},
orderBy: { createdAt: 'desc' },
take: limit,
skip,
}),
prisma.comment.count({ where }),
]);
const transformed = comments.map((c) => ({
id: c.id,
mediaId: c.mediaId,
videoTitle: c.media.filename,
content: c.content,
createdAt: c.createdAt.toISOString(),
safetyStatus: c.safetyStatus,
safetyCategories: c.safetyCategories,
safetyReasoning: c.safetyReasoning,
isHidden: c.isHidden,
hiddenAt: c.hiddenAt?.toISOString() ?? null,
hiddenReason: c.hiddenReason,
moderationNotes: c.moderationNotes,
user: c.user
? { id: c.user.id, name: c.user.name || c.user.email, email: c.user.email }
: null,
moderation: c.moderation
? {
id: c.moderation.id,
status: c.moderation.status,
moderatedAt: c.moderation.moderatedAt?.toISOString() ?? null,
reason: c.moderation.reason,
moderator: c.moderation.moderator
? { id: c.moderation.moderator.id, name: c.moderation.moderator.name }
: null,
}
: null,
}));
return reply.send({
comments: transformed,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
console.error('Failed to fetch admin comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
}
);
/**
* PATCH /admin/comments/:id/approve
* Set safetyStatus to 'safe', create/update CommentModeration
*/
fastify.patch(
'/admin/comments/:id/approve',
async (
request: FastifyRequest<{ Params: CommentIdParams }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
await prisma.$transaction([
prisma.comment.update({
where: { id: commentId },
data: {
safetyStatus: 'safe',
isHidden: false,
safetyCheckedAt: new Date(),
},
}),
prisma.commentModeration.upsert({
where: { commentId },
update: {
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
create: {
commentId,
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
}),
]);
return reply.send({ message: 'Comment approved' });
} catch (error) {
console.error('Failed to approve comment:', error);
return reply.code(500).send({ message: 'Failed to approve comment' });
}
}
);
/**
* PATCH /admin/comments/:id/hide
* Set isHidden to true, hiddenAt, hiddenReason, create CommentModeration
*/
fastify.patch(
'/admin/comments/:id/hide',
async (
request: FastifyRequest<{ Params: CommentIdParams; Body: HideCommentBody }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
const reason = request.body?.reason || 'manual';
await prisma.$transaction([
prisma.comment.update({
where: { id: commentId },
data: {
isHidden: true,
hiddenAt: new Date(),
hiddenReason: reason,
},
}),
prisma.commentModeration.upsert({
where: { commentId },
update: {
status: 'rejected',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
reason,
},
create: {
commentId,
status: 'rejected',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
reason,
},
}),
]);
return reply.send({ message: 'Comment hidden' });
} catch (error) {
console.error('Failed to hide comment:', error);
return reply.code(500).send({ message: 'Failed to hide comment' });
}
}
);
/**
* PATCH /admin/comments/:id/unhide
* Set isHidden to false, update CommentModeration to approved
*/
fastify.patch(
'/admin/comments/:id/unhide',
async (
request: FastifyRequest<{ Params: CommentIdParams }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
await prisma.$transaction([
prisma.comment.update({
where: { id: commentId },
data: { isHidden: false },
}),
prisma.commentModeration.upsert({
where: { commentId },
update: {
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
create: {
commentId,
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
}),
]);
return reply.send({ message: 'Comment unhidden' });
} catch (error) {
console.error('Failed to unhide comment:', error);
return reply.code(500).send({ message: 'Failed to unhide comment' });
}
}
);
/**
* PUT /admin/comments/:id/notes
* Update moderationNotes field
*/
fastify.put(
'/admin/comments/:id/notes',
async (
request: FastifyRequest<{ Params: CommentIdParams; Body: UpdateNotesBody }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const { notes } = request.body || {};
if (notes === undefined) {
return reply.code(400).send({ message: 'Notes field is required' });
}
await prisma.comment.update({
where: { id: commentId },
data: { moderationNotes: notes },
});
return reply.send({ message: 'Notes updated' });
} catch (error) {
console.error('Failed to update notes:', error);
return reply.code(500).send({ message: 'Failed to update notes' });
}
}
);
/**
* DELETE /admin/comments/:id
* Hard delete comment + moderation record
*/
fastify.delete(
'/admin/comments/:id',
async (
request: FastifyRequest<{ Params: CommentIdParams }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
// Delete moderation record first (FK constraint), then comment
await prisma.$transaction([
prisma.commentModeration.deleteMany({ where: { commentId } }),
prisma.comment.delete({ where: { id: commentId } }),
]);
return reply.send({ message: 'Comment deleted' });
} catch (error) {
console.error('Failed to delete comment:', error);
return reply.code(500).send({ message: 'Failed to delete comment' });
}
}
);
// ========================================================================
// WORD FILTER ADMIN ROUTES
// ========================================================================
/**
* GET /admin/word-filters
* List all words grouped by level
*/
fastify.get(
'/admin/word-filters',
async (_request, reply) => {
try {
const words = await prisma.moderationWordList.findMany({
orderBy: [{ level: 'desc' }, { word: 'asc' }],
include: {
creator: { select: { id: true, name: true } },
},
});
return reply.send({ words });
} catch (error) {
console.error('Failed to fetch word filters:', error);
return reply.code(500).send({ message: 'Failed to fetch word filters' });
}
}
);
/**
* POST /admin/word-filters
* Add a word filter entry
*/
fastify.post(
'/admin/word-filters',
async (
request: FastifyRequest<{ Body: AddWordBody }>,
reply
) => {
try {
const { word, level } = request.body || {};
if (!word || !word.trim()) {
return reply.code(400).send({ message: 'Word is required' });
}
if (!['low', 'medium', 'high', 'custom'].includes(level)) {
return reply.code(400).send({ message: 'Level must be low, medium, high, or custom' });
}
// Check for duplicates
const existing = await prisma.moderationWordList.findFirst({
where: { word: { equals: word.trim(), mode: 'insensitive' } },
});
if (existing) {
return reply.code(409).send({ message: 'Word already exists in filter list' });
}
const entry = await prisma.moderationWordList.create({
data: {
word: word.trim().toLowerCase(),
level,
createdBy: request.user!.id,
},
});
invalidateWordListCache();
return reply.code(201).send(entry);
} catch (error) {
console.error('Failed to add word filter:', error);
return reply.code(500).send({ message: 'Failed to add word filter' });
}
}
);
/**
* DELETE /admin/word-filters/:id
* Remove word from filter list
*/
fastify.delete(
'/admin/word-filters/:id',
async (
request: FastifyRequest<{ Params: WordFilterIdParams }>,
reply
) => {
try {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) {
return reply.code(400).send({ message: 'Invalid word filter ID' });
}
await prisma.moderationWordList.delete({ where: { id } });
invalidateWordListCache();
return reply.send({ message: 'Word filter removed' });
} catch (error) {
console.error('Failed to delete word filter:', error);
return reply.code(500).send({ message: 'Failed to delete word filter' });
}
}
);
}

View File

@@ -1,9 +1,10 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { FastifyInstance, FastifyRequest } from 'fastify';
import { randomUUID } from 'crypto';
import { prisma } from '../../../config/database';
import { broadcastCommentToVideo } from './chat-stream.routes.js';
import { v4 as uuidv4 } from 'uuid';
const prisma = new PrismaClient();
import { optionalAuth } from '../middleware/auth';
import { checkContent } from '../services/word-filter.service';
import { notifyUser } from './chat-notifications.routes';
// Rate limiting map: userId/sessionId -> array of timestamps
const commentRateLimitMap = new Map<string, number[]>();
@@ -31,7 +32,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
Params: { id: string };
Querystring: GetCommentsQuery;
}>,
reply: FastifyReply
reply
) => {
try {
const videoId = parseInt(request.params.id, 10);
@@ -103,8 +104,11 @@ export async function commentsRoutes(fastify: FastifyInstance) {
Params: { id: string };
Body: CreateCommentBody;
}>,
reply: FastifyReply
reply
) => {
// Optionally authenticate (attaches request.user if Bearer token present)
await optionalAuth(request, reply);
try {
const videoId = parseInt(request.params.id, 10);
const { content } = request.body;
@@ -123,28 +127,31 @@ export async function commentsRoutes(fastify: FastifyInstance) {
});
}
// Get or create session
let sessionId = request.session?.sessionId;
// Get session ID from X-Session-ID header (set by frontend)
let sessionId = request.headers['x-session-id'] as string | undefined;
let userId: string | null = null;
// Check if user is authenticated (from JWT or session)
// Check if user is authenticated (from optionalAuth preHandler)
if (request.user) {
userId = request.user.id;
}
// If no session exists, create one
// If no session ID from header, generate one
if (!sessionId) {
sessionId = uuidv4();
// Create a minimal session record
await prisma.session.create({
data: {
id: sessionId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || '',
},
});
sessionId = randomUUID();
}
// Ensure session record exists
await prisma.session.upsert({
where: { id: sessionId },
update: {},
create: {
id: sessionId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || '',
},
});
// Rate limiting check
const rateLimitKey = userId || sessionId;
const now = Date.now();
@@ -162,6 +169,31 @@ export async function commentsRoutes(fastify: FastifyInstance) {
recentTimestamps.push(now);
commentRateLimitMap.set(rateLimitKey, recentTimestamps);
// Run word filter check
const filterResult = await checkContent(content.trim());
// High-severity words: block submission entirely
if (filterResult.blocked) {
return reply.code(400).send({
message: 'Your comment contains content that is not allowed.',
});
}
// Determine safety status and hidden state based on filter result
let safetyStatus = 'pending';
let isHidden = false;
let hiddenReason: string | null = null;
if (filterResult.autoHide) {
// Medium-severity: save but auto-hide
safetyStatus = 'flagged';
isHidden = true;
hiddenReason = 'word_filter';
} else if (filterResult.flagged) {
// Low-severity: visible but flagged for review
safetyStatus = 'flagged';
}
// Create comment
const newComment = await prisma.comment.create({
data: {
@@ -169,7 +201,13 @@ export async function commentsRoutes(fastify: FastifyInstance) {
sessionId,
userId,
content: content.trim(),
safetyStatus: 'pending', // Will be checked by moderation system
safetyStatus,
isHidden,
hiddenReason,
safetyReasoning: filterResult.reason || null,
safetyCategories: filterResult.matchedWords.length > 0
? (filterResult.matchedWords as any)
: undefined,
},
include: {
user: {
@@ -182,7 +220,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
},
});
// Broadcast to SSE subscribers
// Broadcast to SSE subscribers (only if not hidden)
const broadcastData = {
id: newComment.id,
content: newComment.content,
@@ -196,7 +234,47 @@ export async function commentsRoutes(fastify: FastifyInstance) {
: null,
};
broadcastCommentToVideo(videoId, broadcastData);
if (!isHidden) {
broadcastCommentToVideo(videoId, broadcastData);
// Notify other users who commented on this video
try {
const otherCommenters = await prisma.comment.findMany({
where: {
mediaId: videoId,
userId: { not: null, ...(userId ? { not: userId } : {}) },
},
select: { userId: true },
distinct: ['userId'],
});
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { filename: true },
});
const commenterName = newComment.user?.name || newComment.user?.email || 'Someone';
const contentPreview = content.trim().length > 80
? content.trim().substring(0, 80) + '...'
: content.trim();
for (const { userId: targetUserId } of otherCommenters) {
if (targetUserId) {
notifyUser(targetUserId, {
type: 'chat_reply',
videoId,
videoTitle: video?.filename || `Video #${videoId}`,
commentId: newComment.id,
commenterName,
contentPreview,
});
}
}
} catch (notifyErr) {
// Non-critical: don't fail the comment creation
console.error('Failed to send chat notifications:', notifyErr);
}
}
return reply.code(201).send(broadcastData);
} catch (error) {

View File

@@ -0,0 +1,198 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { z } from 'zod';
import { requireAdminRole } from '../middleware/auth';
import { videoFetchQueueService } from '../../../services/video-fetch-queue.service';
import { logger } from '../../../utils/logger';
// Validation schema for fetch submission
const FetchBodySchema = z.object({
urls: z.array(z.string().url()).min(1, 'At least one URL is required').max(20, 'Maximum 20 URLs per submission'),
});
export async function fetchRoutes(fastify: FastifyInstance) {
/**
* POST /fetch — Submit a new fetch job
*/
fastify.post(
'/fetch',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
try {
const body = FetchBodySchema.parse(request.body);
const result = await videoFetchQueueService.submitFetch(
body.urls,
request.user!.id
);
return reply.code(201).send({
message: `Fetch job submitted with ${result.urlCount} URL(s)`,
jobId: result.jobId,
urlCount: result.urlCount,
urls: result.sanitizedUrls,
});
} catch (err) {
if (err instanceof z.ZodError) {
return reply.code(400).send({
message: 'Invalid request',
errors: err.errors.map(e => e.message),
});
}
const message = err instanceof Error ? err.message : 'Failed to submit fetch job';
const statusCode = message.includes('No valid URLs') || message.includes('Maximum 20') ? 400 : 500;
return reply.code(statusCode).send({ message });
}
}
);
/**
* GET /fetch/jobs — List recent fetch jobs
*/
fastify.get(
'/fetch/jobs',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
try {
const query = request.query as { limit?: string };
const limit = Math.min(parseInt(query.limit || '20'), 50);
const jobs = await videoFetchQueueService.getRecentJobs(limit);
return reply.send({ jobs });
} catch (err) {
logger.error('Failed to list fetch jobs', { error: err });
return reply.code(500).send({ message: 'Failed to list fetch jobs' });
}
}
);
/**
* GET /fetch/jobs/:jobId — Get single job detail
*/
fastify.get(
'/fetch/jobs/:jobId',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
try {
const { jobId } = request.params as { jobId: string };
const job = await videoFetchQueueService.getJob(jobId);
if (!job) {
return reply.code(404).send({ message: 'Job not found' });
}
// Include log lines
const log = await videoFetchQueueService.getJobLog(jobId);
return reply.send({ ...job, log });
} catch (err) {
logger.error('Failed to get fetch job', { error: err });
return reply.code(500).send({ message: 'Failed to get fetch job' });
}
}
);
/**
* GET /fetch/jobs/:jobId/log — SSE stream of job log lines
*/
fastify.get(
'/fetch/jobs/:jobId/log',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
const { jobId } = request.params as { jobId: string };
const job = await videoFetchQueueService.getJob(jobId);
if (!job) {
return reply.code(404).send({ message: 'Job not found' });
}
// Set up SSE headers using raw response
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
// Send existing log lines
const existingLog = await videoFetchQueueService.getJobLog(jobId);
for (const line of existingLog) {
reply.raw.write(`data: ${JSON.stringify({ type: 'log', data: line })}\n\n`);
}
// Send current progress
if (typeof job.progress === 'number' && job.progress > 0) {
reply.raw.write(`event: progress\ndata: ${job.progress}\n\n`);
}
// If job is already done, send final status and close
if (job.state === 'completed' || job.state === 'failed') {
reply.raw.write(`data: ${JSON.stringify({ type: 'status', status: job.state })}\n\n`);
reply.raw.write('event: done\ndata: done\n\n');
reply.raw.end();
return;
}
// Subscribe to real-time log updates via Redis pub/sub
const subscriber = videoFetchQueueService.createLogSubscriber(jobId);
const cleanup = () => {
try {
subscriber.unsubscribe();
subscriber.quit();
} catch {}
};
subscriber.on('message', (_channel: string, message: string) => {
try {
if (message === '__DONE__') {
reply.raw.write('event: done\ndata: done\n\n');
cleanup();
reply.raw.end();
} else if (message.startsWith('__PROGRESS__:')) {
const percent = message.slice('__PROGRESS__:'.length);
reply.raw.write(`event: progress\ndata: ${percent}\n\n`);
} else {
reply.raw.write(`data: ${JSON.stringify({ type: 'log', data: message })}\n\n`);
}
} catch {
cleanup();
}
});
// Clean up on disconnect
request.raw.on('close', cleanup);
request.raw.on('error', cleanup);
// Safety timeout: close after 2 hours
const timeout = setTimeout(() => {
cleanup();
try { reply.raw.end(); } catch {}
}, 2 * 60 * 60 * 1000);
request.raw.on('close', () => clearTimeout(timeout));
}
);
/**
* DELETE /fetch/jobs/:jobId — Cancel a running/waiting job
*/
fastify.delete(
'/fetch/jobs/:jobId',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
try {
const { jobId } = request.params as { jobId: string };
const cancelled = await videoFetchQueueService.cancelJob(jobId);
if (!cancelled) {
return reply.code(400).send({ message: 'Job cannot be cancelled (may be already completed or not found)' });
}
return reply.send({ message: 'Job cancelled', jobId });
} catch (err) {
logger.error('Failed to cancel fetch job', { error: err });
return reply.code(500).send({ message: 'Failed to cancel fetch job' });
}
}
);
}

View File

@@ -0,0 +1,386 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
interface PaginationQuery {
limit?: string;
offset?: string;
search?: string;
sortBy?: string;
sortOrder?: string;
}
interface PlaylistParams {
id: string;
}
interface UpdatePlaylistBody {
name?: string;
description?: string;
isPublic?: boolean;
}
interface ReorderBody {
items: Array<{ playlistId: number; position: number }>;
}
export async function playlistsAdminRoutes(fastify: FastifyInstance) {
// GET /api/media/playlists - All playlists (admin)
fastify.get(
'/playlists',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
const limit = Math.min(parseInt(request.query.limit || '25'), 100);
const offset = parseInt(request.query.offset || '0');
const search = request.query.search;
const sortBy = request.query.sortBy || 'createdAt';
const sortOrder = request.query.sortOrder === 'asc' ? 'asc' : 'desc';
const allowedSortFields = ['name', 'videoCount', 'totalDurationSeconds', 'viewCount', 'createdAt'];
const sortField = allowedSortFields.includes(sortBy) ? sortBy : 'createdAt';
const where: any = {};
if (search) {
where.name = { contains: search, mode: 'insensitive' };
}
const playlists = await prisma.playlist.findMany({
where,
select: {
id: true,
name: true,
description: true,
isPublic: true,
videoCount: true,
totalDurationSeconds: true,
viewCount: true,
createdAt: true,
updatedAt: true,
user: {
select: { id: true, name: true, email: true },
},
featured: {
select: { id: true, position: true, featuredAt: true },
},
},
orderBy: { [sortField]: sortOrder },
take: limit,
skip: offset,
});
const total = await prisma.playlist.count({ where });
return {
data: playlists.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
isPublic: p.isPublic,
videoCount: p.videoCount ?? 0,
totalDurationSeconds: p.totalDurationSeconds ?? 0,
viewCount: p.viewCount ?? 0,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
creator: {
id: p.user.id,
name: p.user.name,
email: p.user.email,
},
isFeatured: !!p.featured,
featuredPosition: p.featured?.position ?? null,
featuredAt: p.featured?.featuredAt ?? null,
})),
total,
limit,
offset,
};
}
);
// GET /api/media/playlists/featured - Featured playlists (admin)
fastify.get(
'/playlists/featured',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply) => {
const featured = await prisma.featuredPlaylist.findMany({
orderBy: { position: 'asc' },
include: {
playlist: {
select: {
id: true,
name: true,
description: true,
isPublic: true,
videoCount: true,
totalDurationSeconds: true,
viewCount: true,
user: {
select: { id: true, name: true, email: true },
},
},
},
featurer: {
select: { id: true, name: true, email: true },
},
},
});
return {
data: featured.map((f) => ({
id: f.id,
playlistId: f.playlistId,
position: f.position,
featuredBy: f.featurer
? { id: f.featurer.id, name: f.featurer.name, email: f.featurer.email }
: null,
featuredAt: f.featuredAt,
playlist: {
id: f.playlist.id,
name: f.playlist.name,
description: f.playlist.description,
isPublic: f.playlist.isPublic,
videoCount: f.playlist.videoCount ?? 0,
totalDurationSeconds: f.playlist.totalDurationSeconds ?? 0,
viewCount: f.playlist.viewCount ?? 0,
creator: {
id: f.playlist.user.id,
name: f.playlist.user.name,
email: f.playlist.user.email,
},
},
})),
};
}
);
// POST /api/media/playlists/:id/feature - Feature a playlist
fastify.post(
'/playlists/:id/feature',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: { id: true },
});
if (!playlist) {
return reply.code(404).send({ message: 'Playlist not found' });
}
// Check if already featured
const existing = await prisma.featuredPlaylist.findUnique({
where: { playlistId },
});
if (existing) {
return reply.code(409).send({ message: 'Playlist is already featured' });
}
// Get next position
const maxPos = await prisma.featuredPlaylist.aggregate({
_max: { position: true },
});
const nextPosition = (maxPos._max.position ?? -1) + 1;
const featured = await prisma.featuredPlaylist.create({
data: {
playlistId,
position: nextPosition,
featuredBy: request.user!.id,
featuredAt: new Date(),
},
});
return reply.code(201).send(featured);
}
);
// DELETE /api/media/playlists/:id/feature - Unfeature a playlist
fastify.delete(
'/playlists/:id/feature',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const existing = await prisma.featuredPlaylist.findUnique({
where: { playlistId },
});
if (!existing) {
return reply.code(404).send({ message: 'Playlist is not featured' });
}
await prisma.featuredPlaylist.delete({ where: { playlistId } });
return { success: true };
}
);
// PUT /api/media/playlists/featured/reorder - Reorder featured playlists
fastify.put(
'/playlists/featured/reorder',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Body: ReorderBody }>, reply) => {
const { items } = request.body;
if (!items || !Array.isArray(items)) {
return reply.code(400).send({ message: 'items array is required' });
}
await prisma.$transaction(
items.map((item) =>
prisma.featuredPlaylist.update({
where: { playlistId: item.playlistId },
data: { position: item.position },
})
)
);
return { success: true };
}
);
// PUT /api/media/playlists/:id - Admin update playlist metadata
fastify.put(
'/playlists/:id',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: { id: true },
});
if (!playlist) {
return reply.code(404).send({ message: 'Playlist not found' });
}
const { name, description, isPublic } = request.body || {};
const data: any = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (isPublic !== undefined) data.isPublic = isPublic;
if (Object.keys(data).length === 0) {
return reply.code(400).send({ message: 'No fields to update' });
}
try {
const updated = await prisma.playlist.update({
where: { id: playlistId },
data,
select: {
id: true,
name: true,
description: true,
isPublic: true,
videoCount: true,
totalDurationSeconds: true,
viewCount: true,
createdAt: true,
updatedAt: true,
},
});
return updated;
} catch (error: any) {
if (error.code === 'P2002') {
return reply.code(409).send({ message: 'A playlist with this name already exists' });
}
throw error;
}
}
);
// POST /api/media/playlists/:id/duplicate - Duplicate a playlist
fastify.post(
'/playlists/:id/duplicate',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const source = await prisma.playlist.findUnique({
where: { id: playlistId },
select: {
name: true,
description: true,
videos: {
select: { mediaId: true, position: true },
orderBy: { position: 'asc' },
},
},
});
if (!source) {
return reply.code(404).send({ message: 'Playlist not found' });
}
const copyName = `Copy of ${source.name}`.slice(0, 100);
const newPlaylist = await prisma.playlist.create({
data: {
name: copyName,
description: source.description,
isPublic: false,
userId: request.user!.id,
videoCount: source.videos.length,
},
});
if (source.videos.length > 0) {
await prisma.playlistVideo.createMany({
data: source.videos.map((v) => ({
playlistId: newPlaylist.id,
mediaId: v.mediaId,
position: v.position,
})),
});
}
return reply.code(201).send({
id: newPlaylist.id,
name: newPlaylist.name,
description: newPlaylist.description,
isPublic: newPlaylist.isPublic,
videoCount: source.videos.length,
});
}
);
// DELETE /api/media/playlists/:id - Admin delete any playlist
fastify.delete(
'/playlists/:id',
{ preHandler: requireAdminRole },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: { id: true },
});
if (!playlist) {
return reply.code(404).send({ message: 'Playlist not found' });
}
await prisma.playlist.delete({ where: { id: playlistId } });
return { success: true };
}
);
}

View File

@@ -0,0 +1,376 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { logger } from '../../../utils/logger';
interface PaginationQuery {
limit?: string;
offset?: string;
search?: string;
}
interface PlaylistParams {
id: string;
}
interface ShareParams {
token: string;
}
interface PlaylistIdQuery {
shareToken?: string;
}
const playlistSelect = {
id: true,
name: true,
description: true,
isPublic: true,
shareToken: true,
videoCount: true,
totalDurationSeconds: true,
viewCount: true,
createdAt: true,
updatedAt: true,
userId: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
featured: {
select: {
id: true,
position: true,
},
},
videos: {
select: {
media: {
select: {
thumbnailPath: true,
id: true,
},
},
},
orderBy: { position: 'asc' as const },
take: 1,
},
};
function formatPlaylistSummary(playlist: any, requestUserId?: string) {
const firstVideo = playlist.videos?.[0]?.media;
return {
id: playlist.id,
name: playlist.name,
description: playlist.description,
isPublic: playlist.isPublic,
shareToken: playlist.userId === requestUserId ? playlist.shareToken : undefined,
videoCount: playlist.videoCount ?? 0,
totalDurationSeconds: playlist.totalDurationSeconds ?? 0,
viewCount: playlist.viewCount ?? 0,
thumbnailUrl: firstVideo?.thumbnailPath
? `/media/videos/${firstVideo.id}/thumbnail`
: null,
createdAt: playlist.createdAt,
updatedAt: playlist.updatedAt,
creator: {
id: playlist.user.id,
name: playlist.user.name,
email: playlist.user.email,
},
isFeatured: !!playlist.featured,
featuredPosition: playlist.featured?.position ?? null,
isOwner: playlist.userId === requestUserId,
};
}
export async function playlistsPublicRoutes(fastify: FastifyInstance) {
// GET /api/playlists/featured - Get featured playlists
fastify.get(
'/featured',
{ preHandler: optionalAuth },
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
const limit = Math.min(parseInt(request.query.limit || '12'), 50);
const offset = parseInt(request.query.offset || '0');
const featured = await prisma.featuredPlaylist.findMany({
orderBy: { position: 'asc' },
take: limit,
skip: offset,
include: {
playlist: {
select: playlistSelect,
},
},
});
const total = await prisma.featuredPlaylist.count();
return {
data: featured
.filter((f) => f.playlist.isPublic)
.map((f) => formatPlaylistSummary(f.playlist, request.user?.id)),
total,
limit,
offset,
};
}
);
// GET /api/playlists/popular - Get popular public playlists
fastify.get(
'/popular',
{ preHandler: optionalAuth },
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
const limit = Math.min(parseInt(request.query.limit || '12'), 100);
const offset = parseInt(request.query.offset || '0');
const search = request.query.search;
const where: any = { isPublic: true };
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const playlists = await prisma.playlist.findMany({
where,
select: playlistSelect,
orderBy: { viewCount: 'desc' },
take: limit,
skip: offset,
});
const total = await prisma.playlist.count({ where });
return {
data: playlists.map((p) => formatPlaylistSummary(p, request.user?.id)),
total,
limit,
offset,
};
}
);
// GET /api/playlists/share/:token - Get playlist by share token
fastify.get(
'/share/:token',
{ preHandler: optionalAuth },
async (request: FastifyRequest<{ Params: ShareParams }>, reply) => {
const { token } = request.params;
const playlist = await prisma.playlist.findUnique({
where: { shareToken: token },
select: {
...playlistSelect,
videos: {
select: {
id: true,
mediaId: true,
position: true,
addedAt: true,
media: {
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
viewCount: true,
isLocked: true,
isPublished: true,
createdAt: true,
},
},
},
orderBy: { position: 'asc' },
},
},
});
if (!playlist) {
return reply.code(404).send({ message: 'Playlist not found' });
}
const summary = formatPlaylistSummary(playlist, request.user?.id);
return {
...summary,
videos: playlist.videos
.filter((v) => v.media.isPublished)
.map((v) => ({
id: v.id,
mediaId: v.mediaId,
position: v.position,
addedAt: v.addedAt,
video: {
id: v.media.id,
title: v.media.title,
filename: v.media.filename,
durationSeconds: v.media.durationSeconds,
quality: v.media.quality,
orientation: v.media.orientation,
thumbnailUrl: v.media.thumbnailPath
? `/media/videos/${v.media.id}/thumbnail`
: null,
viewCount: v.media.viewCount ?? 0,
isLocked: v.media.isLocked ?? false,
createdAt: v.media.createdAt,
},
})),
};
}
);
// GET /api/playlists/:id - Get playlist detail
fastify.get(
'/:id',
{ preHandler: optionalAuth },
async (
request: FastifyRequest<{ Params: PlaylistParams; Querystring: PlaylistIdQuery }>,
reply
) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: {
...playlistSelect,
videos: {
select: {
id: true,
mediaId: true,
position: true,
addedAt: true,
media: {
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
viewCount: true,
isLocked: true,
isPublished: true,
createdAt: true,
},
},
},
orderBy: { position: 'asc' },
},
},
});
if (!playlist) {
return reply.code(404).send({ message: 'Playlist not found' });
}
// Access control: public, owner, or valid share token
const isOwner = request.user?.id === playlist.userId;
const isPublic = playlist.isPublic === true;
const hasShareToken =
request.query.shareToken && request.query.shareToken === playlist.shareToken;
if (!isPublic && !isOwner && !hasShareToken) {
return reply.code(404).send({ message: 'Playlist not found' });
}
const summary = formatPlaylistSummary(playlist, request.user?.id);
return {
...summary,
videos: playlist.videos
.filter((v) => v.media.isPublished)
.map((v) => ({
id: v.id,
mediaId: v.mediaId,
position: v.position,
addedAt: v.addedAt,
video: {
id: v.media.id,
title: v.media.title,
filename: v.media.filename,
durationSeconds: v.media.durationSeconds,
quality: v.media.quality,
orientation: v.media.orientation,
thumbnailUrl: v.media.thumbnailPath
? `/media/videos/${v.media.id}/thumbnail`
: null,
viewCount: v.media.viewCount ?? 0,
isLocked: v.media.isLocked ?? false,
createdAt: v.media.createdAt,
},
})),
};
}
);
// POST /api/playlists/:id/view - Record playlist view
fastify.post(
'/:id/view',
{ preHandler: optionalAuth },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const sessionId = request.headers['x-session-id'] as string;
if (!sessionId) {
return reply.code(400).send({ message: 'X-Session-ID header required' });
}
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: { id: true },
});
if (!playlist) {
return reply.code(404).send({ message: 'Playlist not found' });
}
try {
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId },
update: { lastSeenAt: new Date() },
});
// Check if already viewed recently (within 30 minutes)
const recentView = await prisma.playlistView.findFirst({
where: {
playlistId,
sessionId,
createdAt: { gte: new Date(Date.now() - 30 * 60 * 1000) },
},
});
if (!recentView) {
await prisma.playlistView.create({
data: { playlistId, sessionId },
});
await prisma.playlist.update({
where: { id: playlistId },
data: { viewCount: { increment: 1 } },
});
}
return { success: true };
} catch (error) {
logger.error('Failed to record playlist view', { error, playlistId, sessionId });
return { success: true }; // Don't fail the request for analytics errors
}
}
);
}

View File

@@ -0,0 +1,450 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { authenticate } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { randomUUID } from 'crypto';
interface PlaylistParams {
id: string;
}
interface VideoParams {
id: string;
mediaId: string;
}
interface CreatePlaylistBody {
name: string;
description?: string;
isPublic?: boolean;
}
interface UpdatePlaylistBody {
name?: string;
description?: string;
isPublic?: boolean;
}
interface AddVideoBody {
mediaId: number;
}
interface ReorderBody {
items: Array<{ mediaId: number; position: number }>;
}
async function updatePlaylistCounters(playlistId: number) {
const stats = await prisma.playlistVideo.aggregate({
where: { playlistId },
_count: { id: true },
});
// Sum duration from joined videos
const videos = await prisma.playlistVideo.findMany({
where: { playlistId },
select: {
media: {
select: { durationSeconds: true },
},
},
});
const totalDuration = videos.reduce(
(sum, v) => sum + (v.media.durationSeconds ?? 0),
0
);
await prisma.playlist.update({
where: { id: playlistId },
data: {
videoCount: stats._count.id,
totalDurationSeconds: totalDuration,
updatedAt: new Date(),
},
});
}
async function checkOwnership(playlistId: number, userId: string) {
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
select: { userId: true },
});
if (!playlist) return null;
if (playlist.userId !== userId) return false;
return true;
}
export async function playlistsUserRoutes(fastify: FastifyInstance) {
// GET /api/playlists/my - Current user's playlists
fastify.get(
'/my',
{ preHandler: authenticate },
async (request: FastifyRequest, reply) => {
const userId = request.user!.id;
const playlists = await prisma.playlist.findMany({
where: { userId },
select: {
id: true,
name: true,
description: true,
isPublic: true,
shareToken: true,
videoCount: true,
totalDurationSeconds: true,
viewCount: true,
createdAt: true,
updatedAt: true,
featured: {
select: { id: true, position: true },
},
videos: {
select: {
media: {
select: { thumbnailPath: true, id: true },
},
},
orderBy: { position: 'asc' as const },
take: 1,
},
},
orderBy: { updatedAt: 'desc' },
});
return {
data: playlists.map((p) => {
const firstVideo = p.videos?.[0]?.media;
return {
id: p.id,
name: p.name,
description: p.description,
isPublic: p.isPublic,
shareToken: p.shareToken,
videoCount: p.videoCount ?? 0,
totalDurationSeconds: p.totalDurationSeconds ?? 0,
viewCount: p.viewCount ?? 0,
thumbnailUrl: firstVideo?.thumbnailPath
? `/media/videos/${firstVideo.id}/thumbnail`
: null,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
isFeatured: !!p.featured,
featuredPosition: p.featured?.position ?? null,
isOwner: true,
};
}),
};
}
);
// POST /api/playlists/ - Create playlist
fastify.post(
'/',
{ preHandler: authenticate },
async (request: FastifyRequest<{ Body: CreatePlaylistBody }>, reply) => {
const userId = request.user!.id;
const { name, description, isPublic } = request.body;
if (!name || name.trim().length === 0 || name.length > 100) {
return reply.code(400).send({ message: 'Name is required (1-100 characters)' });
}
try {
const playlist = await prisma.playlist.create({
data: {
userId,
name: name.trim(),
description: description?.trim() || null,
isPublic: isPublic ?? false,
updatedAt: new Date(),
},
});
return reply.code(201).send(playlist);
} catch (error: any) {
if (error.code === 'P2002') {
return reply
.code(409)
.send({ message: 'You already have a playlist with this name' });
}
throw error;
}
}
);
// PUT /api/playlists/:id - Update playlist
fastify.put(
'/:id',
{ preHandler: authenticate },
async (
request: FastifyRequest<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>,
reply
) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
const { name, description, isPublic } = request.body;
const data: any = { updatedAt: new Date() };
if (name !== undefined) {
if (name.trim().length === 0 || name.length > 100) {
return reply
.code(400)
.send({ message: 'Name must be 1-100 characters' });
}
data.name = name.trim();
}
if (description !== undefined) data.description = description?.trim() || null;
if (isPublic !== undefined) data.isPublic = isPublic;
try {
const updated = await prisma.playlist.update({
where: { id: playlistId },
data,
});
return updated;
} catch (error: any) {
if (error.code === 'P2002') {
return reply
.code(409)
.send({ message: 'You already have a playlist with this name' });
}
throw error;
}
}
);
// DELETE /api/playlists/:id - Delete playlist
fastify.delete(
'/:id',
{ preHandler: authenticate },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
await prisma.playlist.delete({ where: { id: playlistId } });
return { success: true };
}
);
// POST /api/playlists/:id/videos - Add video to playlist
fastify.post(
'/:id/videos',
{ preHandler: authenticate },
async (
request: FastifyRequest<{ Params: PlaylistParams; Body: AddVideoBody }>,
reply
) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
const { mediaId } = request.body;
if (!mediaId) {
return reply.code(400).send({ message: 'mediaId is required' });
}
// Check video exists and is published
const video = await prisma.video.findUnique({
where: { id: mediaId },
select: { id: true, isPublished: true },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Check for duplicate
const existing = await prisma.playlistVideo.findFirst({
where: { playlistId, mediaId },
});
if (existing) {
return reply.code(409).send({ message: 'Video already in playlist' });
}
// Get next position
const maxPos = await prisma.playlistVideo.aggregate({
where: { playlistId },
_max: { position: true },
});
const nextPosition = (maxPos._max.position ?? -1) + 1;
await prisma.playlistVideo.create({
data: {
playlistId,
mediaId,
position: nextPosition,
},
});
await updatePlaylistCounters(playlistId);
return { success: true, position: nextPosition };
}
);
// DELETE /api/playlists/:id/videos/:mediaId - Remove video from playlist
fastify.delete(
'/:id/videos/:mediaId',
{ preHandler: authenticate },
async (request: FastifyRequest<{ Params: VideoParams }>, reply) => {
const playlistId = parseInt(request.params.id);
const mediaId = parseInt(request.params.mediaId);
if (isNaN(playlistId) || isNaN(mediaId)) {
return reply.code(400).send({ message: 'Invalid IDs' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
const pv = await prisma.playlistVideo.findFirst({
where: { playlistId, mediaId },
});
if (!pv) {
return reply.code(404).send({ message: 'Video not in playlist' });
}
await prisma.playlistVideo.delete({ where: { id: pv.id } });
await updatePlaylistCounters(playlistId);
return { success: true };
}
);
// PUT /api/playlists/:id/videos/reorder - Reorder videos
fastify.put(
'/:id/videos/reorder',
{ preHandler: authenticate },
async (
request: FastifyRequest<{ Params: PlaylistParams; Body: ReorderBody }>,
reply
) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
const { items } = request.body;
if (!items || !Array.isArray(items)) {
return reply.code(400).send({ message: 'items array is required' });
}
await prisma.$transaction(
items.map((item) =>
prisma.playlistVideo.updateMany({
where: { playlistId, mediaId: item.mediaId },
data: { position: item.position },
})
)
);
await prisma.playlist.update({
where: { id: playlistId },
data: { updatedAt: new Date() },
});
return { success: true };
}
);
// POST /api/playlists/:id/share - Generate share token
fastify.post(
'/:id/share',
{ preHandler: authenticate },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
const shareToken = randomUUID();
await prisma.playlist.update({
where: { id: playlistId },
data: { shareToken, updatedAt: new Date() },
});
return { shareToken };
}
);
// DELETE /api/playlists/:id/share - Revoke share token
fastify.delete(
'/:id/share',
{ preHandler: authenticate },
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
const playlistId = parseInt(request.params.id);
if (isNaN(playlistId)) {
return reply.code(400).send({ message: 'Invalid playlist ID' });
}
const ownership = await checkOwnership(playlistId, request.user!.id);
if (ownership === null) {
return reply.code(404).send({ message: 'Playlist not found' });
}
if (ownership === false) {
return reply.code(403).send({ message: 'Not authorized' });
}
await prisma.playlist.update({
where: { id: playlistId },
data: { shareToken: null, updatedAt: new Date() },
});
return { success: true };
}
);
}

View File

@@ -180,21 +180,6 @@ export async function publicRoutes(fastify: FastifyInstance) {
return videos.map((v) => v.producer).filter(Boolean);
});
// GET /api/public/:id/comments - Get video comments (unauthenticated)
fastify.get(
'/public/:id/comments',
{
preHandler: optionalAuth,
},
async (request: FastifyRequest<{ Params: { id: string }; Querystring: { limit?: string } }>, reply) => {
const videoId = parseInt(request.params.id);
const limit = parseInt(request.query.limit || '200');
// For now, return empty array since comments feature is not yet implemented for public gallery
return { comments: [] };
}
);
// GET /api/public/:id/thumbnail - Get video thumbnail (unauthenticated)
fastify.get(
'/public/:id/thumbnail',
@@ -255,7 +240,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
reply.header('Accept-Ranges', 'bytes');
const stream = createReadStream(thumbnailPath);
reply.send(stream);
return reply.send(stream);
}
);
@@ -338,7 +323,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
reply.header('Content-Type', mimeType);
const stream = createReadStream(filePath, { start, end });
reply.send(stream);
return reply.send(stream);
} else {
// No range - send full file
reply.header('Content-Length', fileSize);
@@ -346,7 +331,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
reply.header('Accept-Ranges', 'bytes');
const stream = createReadStream(filePath);
reply.send(stream);
return reply.send(stream);
}
}
);

View File

@@ -0,0 +1,172 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { optionalAuth, requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { Prisma } from '@prisma/client';
interface ShortsQuery {
limit?: string;
offset?: string;
sort?: 'recent' | 'popular' | 'random';
}
export async function shortsRoutes(fastify: FastifyInstance) {
/**
* GET /api/shorts - Public shorts feed
* Returns published short videos (<=60s) for the TikTok-style feed
*/
fastify.get(
'/shorts',
{
preHandler: optionalAuth,
},
async (request: FastifyRequest<{ Querystring: ShortsQuery }>, reply) => {
const limit = Math.min(parseInt(request.query.limit || '20'), 50);
const offset = parseInt(request.query.offset || '0');
const sort = request.query.sort || 'recent';
const where: Prisma.VideoWhereInput = {
isShort: true,
isPublished: true,
isLocked: false,
};
// For random sort, use raw query
if (sort === 'random') {
const total = await prisma.video.count({ where });
const shorts = await prisma.$queryRaw<any[]>`
SELECT id, title, filename, duration_seconds as "durationSeconds",
quality, orientation, thumbnail_path as "thumbnailPath",
view_count as "viewCount", upvote_count as "upvoteCount",
comment_count as "commentCount", is_locked as "isLocked",
width, height, published_at as "publishedAt",
category, created_at as "createdAt"
FROM videos
WHERE is_short = true AND is_published = true AND is_locked = false
ORDER BY RANDOM()
LIMIT ${limit} OFFSET ${offset}
`;
const shortsWithUrls = shorts.map((video: any) => ({
...video,
duration: video.durationSeconds,
thumbnailUrl: video.thumbnailPath ? `/public/${video.id}/thumbnail` : null,
videoUrl: `/public/${video.id}/stream`,
}));
return {
shorts: shortsWithUrls,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
};
}
// Standard Prisma query for recent/popular
let orderBy: Prisma.VideoOrderByWithRelationInput;
if (sort === 'popular') {
orderBy = { viewCount: 'desc' };
} else {
orderBy = { publishedAt: 'desc' };
}
const [shorts, total] = await Promise.all([
prisma.video.findMany({
where,
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
isLocked: true,
width: true,
height: true,
publishedAt: true,
category: true,
createdAt: true,
},
orderBy,
take: limit,
skip: offset,
}),
prisma.video.count({ where }),
]);
const shortsWithUrls = shorts.map((video) => ({
...video,
duration: video.durationSeconds,
thumbnailUrl: video.thumbnailPath ? `/public/${video.id}/thumbnail` : null,
videoUrl: `/public/${video.id}/stream`,
}));
return {
shorts: shortsWithUrls,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
};
}
);
/**
* POST /api/shorts/scan - Admin: auto-classify shorts by duration
* Sets isShort=true for videos <=60s, isShort=false for >60s or null duration
*/
fastify.post(
'/shorts/scan',
{
preHandler: requireAdminRole,
},
async (request, reply) => {
try {
const [classified, declassified] = await Promise.all([
// Mark videos <=60s as shorts
prisma.video.updateMany({
where: {
durationSeconds: { not: null, lte: 60 },
isShort: false,
},
data: { isShort: true },
}),
// Unmark videos >60s or with null duration
prisma.video.updateMany({
where: {
OR: [
{ durationSeconds: { gt: 60 } },
{ durationSeconds: null },
],
isShort: true,
},
data: { isShort: false },
}),
]);
const totalShorts = await prisma.video.count({ where: { isShort: true } });
logger.info(`Shorts scan complete: classified=${classified.count}, declassified=${declassified.count}, totalShorts=${totalShorts}`);
return {
classified: classified.count,
declassified: declassified.count,
totalShorts,
};
} catch (error) {
logger.error('Failed to scan shorts', { error });
return reply.code(500).send({ message: 'Failed to scan shorts' });
}
}
);
}

View File

@@ -0,0 +1,112 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { logger } from '../../../utils/logger';
/**
* Public upvote routes for media gallery
* Uses X-Session-ID header for anonymous upvote tracking
*/
export async function upvoteRoutes(fastify: FastifyInstance) {
/**
* POST /public/:id/upvote
* Toggle upvote for a video (session-based, no auth required)
*/
fastify.post(
'/public/:id/upvote',
async (request: FastifyRequest<{ Params: { id: string }; Body: { sessionId?: string } }>, reply) => {
const videoId = parseInt(request.params.id);
const sessionId = (request.headers['x-session-id'] as string) || request.body?.sessionId;
if (!sessionId) {
return reply.code(400).send({ message: 'Session ID required (X-Session-ID header or body.sessionId)' });
}
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Verify video exists and is published
const video = await prisma.video.findFirst({
where: { id: videoId, isPublished: true, isLocked: false },
select: { id: true, upvoteCount: true },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Ensure session record exists
await prisma.session.upsert({
where: { id: sessionId },
create: {
id: sessionId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
firstSeenAt: new Date(),
lastSeenAt: new Date(),
},
update: {
lastSeenAt: new Date(),
},
});
// Check if already upvoted
const existing = await prisma.upvote.findFirst({
where: { mediaId: videoId, sessionId },
});
let upvoted: boolean;
let newCount: number;
if (existing) {
// Remove upvote
await prisma.upvote.delete({ where: { id: existing.id } });
newCount = Math.max(0, video.upvoteCount - 1);
upvoted = false;
} else {
// Add upvote
await prisma.upvote.create({
data: { mediaId: videoId, sessionId },
});
newCount = video.upvoteCount + 1;
upvoted = true;
}
// Update denormalized count on video
await prisma.video.update({
where: { id: videoId },
data: { upvoteCount: newCount },
});
logger.info(`Upvote ${upvoted ? 'added' : 'removed'} for video ${videoId}`, { sessionId: sessionId.substring(0, 8) });
return { upvoted, upvoteCount: newCount };
}
);
/**
* GET /public/:id/upvote-status
* Check if session has upvoted a video
*/
fastify.get(
'/public/:id/upvote-status',
async (request: FastifyRequest<{ Params: { id: string }; Querystring: { sessionId?: string } }>, reply) => {
const videoId = parseInt(request.params.id);
const sessionId = (request.headers['x-session-id'] as string) || request.query.sessionId;
if (!sessionId) {
return { upvoted: false };
}
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
const upvote = await prisma.upvote.findFirst({
where: { mediaId: videoId, sessionId },
});
return { upvoted: !!upvote };
}
);
}

View File

@@ -0,0 +1,405 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import bcrypt from 'bcryptjs';
import { prisma } from '../../../config/database';
import { authenticate } from '../middleware/auth';
interface UpdateSettingsBody {
showOnlineStatus?: boolean;
showCurrentlyWatching?: boolean;
showInFriendActivity?: boolean;
anonymizePublicComments?: boolean;
hidePublicReactions?: boolean;
hidePublicFinishes?: boolean;
allowFriendRequests?: boolean;
closeFriendsOnlyWatching?: boolean;
}
interface UpdateProfileBody {
name?: string;
}
interface ChangePasswordBody {
currentPassword: string;
newPassword: string;
}
interface WatchHistoryQuery {
limit?: string;
offset?: string;
}
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{12,}$/;
export async function userProfileRoutes(fastify: FastifyInstance) {
// ─── Stats ─────────────────────────────────────────────────
/**
* GET /me/stats
* Returns UserStats (upsert if missing), recent daily activity, achievement count
*/
fastify.get(
'/me/stats',
{ preHandler: [authenticate] },
async (request: FastifyRequest, reply) => {
const userId = request.user!.id;
// Upsert UserStats (create with defaults if missing)
const stats = await prisma.userStats.upsert({
where: { userId },
create: { userId },
update: {},
});
// Last 30 days of daily activity
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const dateStr = thirtyDaysAgo.toISOString().split('T')[0];
const dailyActivity = await prisma.userDailyActivity.findMany({
where: {
userId,
activityDate: { gte: dateStr },
},
orderBy: { activityDate: 'asc' },
});
// Achievement count
const achievementCount = await prisma.userAchievement.count({
where: { userId },
});
return reply.send({
stats,
dailyActivity,
achievementCount,
});
}
);
/**
* GET /me/watch-history
* Paginated recent VideoView records with video info
*/
fastify.get(
'/me/watch-history',
{ preHandler: [authenticate] },
async (
request: FastifyRequest<{ Querystring: WatchHistoryQuery }>,
reply
) => {
const userId = request.user!.id;
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
const offset = parseInt(request.query.offset || '0', 10);
const [views, total] = await Promise.all([
prisma.videoView.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
select: {
id: true,
watchTimeSeconds: true,
completed: true,
createdAt: true,
video: {
select: {
id: true,
title: true,
filename: true,
thumbnailPath: true,
durationSeconds: true,
},
},
},
}),
prisma.videoView.count({ where: { userId } }),
]);
return reply.send({ views, total, limit, offset });
}
);
/**
* POST /me/stats/recalculate
* Recompute UserStats from raw data
*/
fastify.post(
'/me/stats/recalculate',
{ preHandler: [authenticate] },
async (request: FastifyRequest, reply) => {
const userId = request.user!.id;
// Aggregate from raw tables
const [viewAgg, commentCount, reactionCount, finishCount, dailyActivity] =
await Promise.all([
prisma.videoView.aggregate({
where: { userId },
_sum: { watchTimeSeconds: true },
_count: true,
}),
prisma.comment.count({ where: { userId } }),
prisma.videoReaction.count({ where: { userId } }),
prisma.userFinish.count({ where: { userId } }),
prisma.userDailyActivity.findMany({
where: { userId },
orderBy: { activityDate: 'desc' },
select: { activityDate: true, firstActivityHour: true },
}),
]);
// Calculate streaks from daily activity
let currentStreak = 0;
let longestStreak = 0;
let tempStreak = 0;
let nightOwlCount = 0;
let earlyBirdCount = 0;
// Sort descending (most recent first) for streak calculation
const sortedDates = dailyActivity
.map((d) => d.activityDate)
.sort()
.reverse();
const today = new Date().toISOString().split('T')[0];
for (let i = 0; i < sortedDates.length; i++) {
const date = new Date(sortedDates[i]);
const expected = new Date(today);
expected.setDate(expected.getDate() - i);
if (date.toISOString().split('T')[0] === expected.toISOString().split('T')[0]) {
tempStreak++;
} else {
break;
}
}
currentStreak = tempStreak;
// Calculate longest streak (ascending order)
const ascending = [...sortedDates].reverse();
tempStreak = 1;
for (let i = 1; i < ascending.length; i++) {
const prev = new Date(ascending[i - 1]);
const curr = new Date(ascending[i]);
const diffMs = curr.getTime() - prev.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
tempStreak++;
} else {
longestStreak = Math.max(longestStreak, tempStreak);
tempStreak = 1;
}
}
longestStreak = Math.max(longestStreak, tempStreak);
if (ascending.length === 0) longestStreak = 0;
// Night owl (activity hour >= 22 or < 5) vs Early bird (5-9)
for (const d of dailyActivity) {
if (d.firstActivityHour != null) {
if (d.firstActivityHour >= 22 || d.firstActivityHour < 5) {
nightOwlCount++;
} else if (d.firstActivityHour >= 5 && d.firstActivityHour < 10) {
earlyBirdCount++;
}
}
}
// Longest single session from VideoView
const longestSession = await prisma.videoView.aggregate({
where: { userId },
_max: { watchTimeSeconds: true },
});
const stats = await prisma.userStats.upsert({
where: { userId },
create: {
userId,
totalWatchTimeSeconds: viewAgg._sum.watchTimeSeconds || 0,
totalVideosWatched: viewAgg._count,
totalCommentsMade: commentCount,
totalUpvotesGiven: reactionCount,
totalFinishes: finishCount,
currentDayStreak: currentStreak,
longestDayStreak: longestStreak,
nightOwlCount,
earlyBirdCount,
longestSingleSession: longestSession._max.watchTimeSeconds || 0,
lastActiveDate: today,
updatedAt: new Date(),
},
update: {
totalWatchTimeSeconds: viewAgg._sum.watchTimeSeconds || 0,
totalVideosWatched: viewAgg._count,
totalCommentsMade: commentCount,
totalUpvotesGiven: reactionCount,
totalFinishes: finishCount,
currentDayStreak: currentStreak,
longestDayStreak: longestStreak,
nightOwlCount,
earlyBirdCount,
longestSingleSession: longestSession._max.watchTimeSeconds || 0,
lastActiveDate: today,
updatedAt: new Date(),
},
});
return reply.send({ stats, recalculated: true });
}
);
// ─── Settings ──────────────────────────────────────────────
/**
* GET /me/settings
* Returns PrivacySettings (upsert defaults if missing) + user profile
*/
fastify.get(
'/me/settings',
{ preHandler: [authenticate] },
async (request: FastifyRequest, reply) => {
const userId = request.user!.id;
const [privacy, user] = await Promise.all([
prisma.privacySettings.upsert({
where: { userId },
create: { userId },
update: {},
}),
prisma.user.findUnique({
where: { id: userId },
select: { name: true, email: true },
}),
]);
return reply.send({ privacy, profile: user });
}
);
/**
* PUT /me/settings
* Update PrivacySettings boolean toggles
*/
fastify.put(
'/me/settings',
{ preHandler: [authenticate] },
async (
request: FastifyRequest<{ Body: UpdateSettingsBody }>,
reply
) => {
const userId = request.user!.id;
const body = request.body;
// Only allow known boolean fields
const allowedFields: (keyof UpdateSettingsBody)[] = [
'showOnlineStatus',
'showCurrentlyWatching',
'showInFriendActivity',
'anonymizePublicComments',
'hidePublicReactions',
'hidePublicFinishes',
'allowFriendRequests',
'closeFriendsOnlyWatching',
];
const updateData: Record<string, boolean> = {};
for (const field of allowedFields) {
if (typeof body[field] === 'boolean') {
updateData[field] = body[field] as boolean;
}
}
const privacy = await prisma.privacySettings.upsert({
where: { userId },
create: { userId, ...updateData, updatedAt: new Date() },
update: { ...updateData, updatedAt: new Date() },
});
return reply.send({ privacy });
}
);
/**
* PUT /me/profile
* Update user name (email is read-only)
*/
fastify.put(
'/me/profile',
{ preHandler: [authenticate] },
async (
request: FastifyRequest<{ Body: UpdateProfileBody }>,
reply
) => {
const userId = request.user!.id;
const { name } = request.body;
if (name !== undefined && (typeof name !== 'string' || name.length > 100)) {
return reply.code(400).send({ message: 'Name must be a string under 100 characters' });
}
const user = await prisma.user.update({
where: { id: userId },
data: { name: name?.trim() || null },
select: { name: true, email: true },
});
return reply.send({ profile: user });
}
);
/**
* PUT /me/password
* Change password (requires current password verification)
*/
fastify.put(
'/me/password',
{ preHandler: [authenticate] },
async (
request: FastifyRequest<{ Body: ChangePasswordBody }>,
reply
) => {
const userId = request.user!.id;
const { currentPassword, newPassword } = request.body;
if (!currentPassword || !newPassword) {
return reply
.code(400)
.send({ message: 'Current password and new password are required' });
}
// Validate new password meets policy
if (!PASSWORD_REGEX.test(newPassword)) {
return reply.code(400).send({
message:
'Password must be at least 12 characters with uppercase, lowercase, and a digit',
});
}
// Fetch current password hash
const user = await prisma.user.findUnique({
where: { id: userId },
select: { password: true },
});
if (!user) {
return reply.code(404).send({ message: 'User not found' });
}
// Verify current password
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
return reply.code(401).send({ message: 'Current password is incorrect' });
}
// Hash and save new password
const hashedPassword = await bcrypt.hash(newPassword, 12);
await prisma.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
return reply.send({ message: 'Password updated successfully' });
}
);
}

View File

@@ -17,6 +17,7 @@ const UpdateVideoSchema = z.object({
tags: z.array(z.string().max(100)).max(50).nullable().optional(),
quality: z.string().max(50).nullable().optional(),
position: z.number().int().min(0).nullable().optional(),
isShort: z.boolean().optional(),
});
export async function videoActionsRoutes(fastify: FastifyInstance) {
@@ -53,6 +54,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
if (updates.tags !== undefined) data.tags = updates.tags;
if (updates.quality !== undefined) data.quality = updates.quality;
if (updates.position !== undefined) data.position = updates.position;
if (updates.isShort !== undefined) data.isShort = updates.isShort;
const updatedVideo = await prisma.video.update({
where: { id: videoId },

View File

@@ -3,8 +3,53 @@ import { createReadStream, stat } from 'fs';
import { access, readFile } from 'fs/promises';
import { join } from 'path';
import { lookup } from 'mime-types';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
/**
* Check if the request is from an authenticated admin user.
* Supports JWT from Authorization header or ?token= query parameter
* (needed for <video src> and <img src> which can't send headers).
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
// Extract token from Authorization header (priority) or query param (fallback)
let token: string | undefined;
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else {
const query = request.query as Record<string, string | undefined>;
token = query.token;
}
if (!token) return false;
// Verify JWT signature
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
// Check admin role from token (multi-role aware)
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
// Verify user is still active in DB
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { status: true },
});
return user?.status === UserStatus.ACTIVE;
} catch {
return false;
}
}
/**
* Parse range header for video seeking
@@ -53,13 +98,12 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database (only published, unlocked videos for public access)
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const video = await prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
isLocked: false,
},
where: admin
? { id: videoId }
: { id: videoId, isPublished: true, isLocked: false },
});
if (!video) {
@@ -155,13 +199,12 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database (only published, unlocked videos for public access)
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const video = await prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
isLocked: false,
},
where: admin
? { id: videoId }
: { id: videoId, isPublished: true, isLocked: false },
});
if (!video) {
@@ -222,13 +265,12 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database (only published, unlocked videos for public access)
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const video = await prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
isLocked: false,
},
where: admin
? { id: videoId }
: { id: videoId, isPublished: true, isLocked: false },
});
if (!video) {

View File

@@ -15,6 +15,7 @@ interface ListVideosQuery {
search?: string;
orientation?: 'H' | 'V';
producers?: string;
isShort?: string;
}
export async function videosRoutes(fastify: FastifyInstance) {
@@ -30,10 +31,15 @@ export async function videosRoutes(fastify: FastifyInstance) {
const search = request.query.search;
const orientation = request.query.orientation;
const producers = request.query.producers?.split(',').filter(Boolean);
const isShort = request.query.isShort;
// Build Prisma WHERE clause
const where: any = {};
if (isShort !== undefined) {
where.isShort = isShort === 'true';
}
if (search) {
where.title = {
contains: search,
@@ -70,6 +76,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
scheduledPublishAt: true,
scheduledUnpublishAt: true,
category: true,
isShort: true,
},
orderBy: {
createdAt: 'desc',

View File

@@ -0,0 +1,95 @@
import { prisma } from '../../../config/database';
import { WordFilterLevel } from '@prisma/client';
export interface WordFilterResult {
blocked: boolean;
autoHide: boolean;
flagged: boolean;
reason?: string;
matchedWords: string[];
highestLevel: WordFilterLevel | null;
}
// In-memory cache of word list (refreshed periodically)
let wordListCache: { word: string; level: WordFilterLevel }[] = [];
let cacheLoadedAt = 0;
const CACHE_TTL = 60 * 1000; // 1 minute
async function loadWordList(): Promise<void> {
const now = Date.now();
if (now - cacheLoadedAt < CACHE_TTL && wordListCache.length > 0) {
return;
}
const words = await prisma.moderationWordList.findMany({
select: { word: true, level: true },
});
wordListCache = words;
cacheLoadedAt = now;
}
/**
* Check content against the moderation word list.
*
* Levels:
* - high: auto-block (reject submission entirely)
* - medium: auto-hide (saved but hidden, pending review)
* - low: flag for review (saved and visible, but flagged)
*/
export async function checkContent(text: string): Promise<WordFilterResult> {
await loadWordList();
if (wordListCache.length === 0) {
return { blocked: false, autoHide: false, flagged: false, matchedWords: [], highestLevel: null };
}
const normalizedText = text.toLowerCase();
const matchedWords: string[] = [];
let highestLevel: WordFilterLevel | null = null;
const levelPriority: Record<string, number> = {
high: 3,
medium: 2,
low: 1,
custom: 0,
};
for (const entry of wordListCache) {
// Word boundary match (case-insensitive)
const escaped = entry.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
if (regex.test(normalizedText)) {
matchedWords.push(entry.word);
if (!highestLevel || levelPriority[entry.level] > levelPriority[highestLevel]) {
highestLevel = entry.level;
}
}
}
if (matchedWords.length === 0) {
return { blocked: false, autoHide: false, flagged: false, matchedWords: [], highestLevel: null };
}
const blocked = highestLevel === 'high';
const autoHide = highestLevel === 'medium' || highestLevel === 'high';
const flagged = highestLevel === 'low';
return {
blocked,
autoHide,
flagged,
reason: `Matched word filter: ${matchedWords.join(', ')} (level: ${highestLevel})`,
matchedWords,
highestLevel,
};
}
/**
* Invalidate the word list cache (call after adding/removing words)
*/
export function invalidateWordListCache(): void {
cacheLoadedAt = 0;
}

View File

@@ -36,6 +36,11 @@ export const updateSiteSettingsSchema = z.object({
emailTestMode: z.boolean().optional(),
testEmailRecipient: z.string().max(255).optional(),
// Registration settings
enablePublicRegistration: z.boolean().optional(),
enableEmailVerification: z.boolean().optional(),
autoApproveVerifiedUsers: z.boolean().optional(),
// Feature toggles
enableInfluence: z.boolean().optional(),
enableMap: z.boolean().optional(),

View File

@@ -1,12 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { z } from 'zod';
import { UserRole, UserStatus } from '@prisma/client';
import { usersService } from './users.service';
import { createUserSchema, updateUserSchema, listUsersSchema } from './users.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { hasAnyRole, ADMIN_ROLES } from '../../utils/roles';
import { prisma } from '../../config/database';
import { emailService } from '../../services/email.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
const router = Router();
@@ -34,10 +38,10 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const isAdmin = ADMIN_ROLES.includes(req.user!.role);
const isAdminUser = hasAnyRole(req.user!, ADMIN_ROLES);
const isSelf = req.user!.id === id;
if (!isAdmin && !isSelf) {
if (!isAdminUser && !isSelf) {
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
return;
}
@@ -71,17 +75,18 @@ router.put(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const isAdmin = ADMIN_ROLES.includes(req.user!.role);
const isAdminUser = hasAnyRole(req.user!, ADMIN_ROLES);
const isSelf = req.user!.id === id;
if (!isAdmin && !isSelf) {
if (!isAdminUser && !isSelf) {
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
return;
}
// Non-admins cannot change role or status
if (!isAdmin) {
// Non-admins cannot change role, roles, or status
if (!isAdminUser) {
delete req.body.role;
delete req.body.roles;
delete req.body.status;
}
@@ -94,6 +99,83 @@ router.put(
}
);
// POST /api/users/:id/approve — approve pending user (admin only)
const approveSchema = z.object({}).optional();
router.post(
'/:id/approve',
requireRole(...ADMIN_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
res.status(404).json({ error: { message: 'User not found', code: 'USER_NOT_FOUND' } });
return;
}
if (user.status !== UserStatus.PENDING_APPROVAL) {
res.status(400).json({ error: { message: 'User is not pending approval', code: 'INVALID_STATUS' } });
return;
}
await prisma.user.update({
where: { id },
data: { status: UserStatus.ACTIVE },
});
// Send approval notification email
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
await emailService.sendAccountApprovedEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
loginUrl: `${adminUrl}/login`,
}).catch(err => logger.error('Failed to send approval email:', err));
res.json({ message: 'User approved', userId: id });
} catch (err) {
next(err);
}
}
);
// POST /api/users/:id/reject — reject pending user (admin only)
const rejectSchema = z.object({
reason: z.string().max(500).optional(),
});
router.post(
'/:id/reject',
requireRole(...ADMIN_ROLES),
validate(rejectSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
res.status(404).json({ error: { message: 'User not found', code: 'USER_NOT_FOUND' } });
return;
}
if (user.status !== UserStatus.PENDING_APPROVAL) {
res.status(400).json({ error: { message: 'User is not pending approval', code: 'INVALID_STATUS' } });
return;
}
await prisma.user.update({
where: { id },
data: { status: UserStatus.INACTIVE },
});
res.json({ message: 'User rejected', userId: id });
} catch (err) {
next(err);
}
}
);
// DELETE /api/users/:id — delete user (admin only)
router.delete(
'/:id',

View File

@@ -11,6 +11,7 @@ export const createUserSchema = z.object({
name: z.string().optional(),
phone: z.string().optional(),
role: z.nativeEnum(UserRole).optional(),
roles: z.array(z.nativeEnum(UserRole)).optional(),
status: z.nativeEnum(UserStatus).optional(),
expiresAt: z.string().datetime().optional(),
expireDays: z.number().int().positive().optional(),
@@ -27,6 +28,7 @@ export const updateUserSchema = z.object({
name: z.string().optional(),
phone: z.string().optional(),
role: z.nativeEnum(UserRole).optional(),
roles: z.array(z.nativeEnum(UserRole)).optional(),
status: z.nativeEnum(UserStatus).optional(),
expiresAt: z.string().datetime().nullable().optional(),
expireDays: z.number().int().positive().nullable().optional(),

View File

@@ -2,6 +2,7 @@ import bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import { getPrimaryRole } from '../../utils/roles';
import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas';
const userSelect = {
@@ -10,6 +11,7 @@ const userSelect = {
name: true,
phone: true,
role: true,
roles: true,
status: true,
permissions: true,
createdVia: true,
@@ -81,10 +83,19 @@ export const usersService = {
const hashedPassword = await bcrypt.hash(data.password, 12);
// Compute roles array and primary role
const roles = data.roles && data.roles.length > 0
? data.roles
: [data.role || 'USER'];
const primaryRole = getPrimaryRole(roles as any);
const user = await prisma.user.create({
data: {
...data,
password: hashedPassword,
role: primaryRole,
roles: JSON.parse(JSON.stringify(roles)),
emailVerified: true, // Admin-created users are pre-verified
expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
},
select: userSelect,
@@ -116,6 +127,18 @@ export const usersService = {
updateData.expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
}
// Sync roles and primary role
if (data.roles) {
updateData.roles = JSON.parse(JSON.stringify(data.roles));
updateData.role = getPrimaryRole(data.roles as any);
} else if (data.role) {
// If only primary role changed, update roles array too
const currentRoles = Array.isArray(existing.roles) ? existing.roles as string[] : [existing.role];
if (!currentRoles.includes(data.role)) {
updateData.roles = JSON.parse(JSON.stringify([...currentRoles, data.role]));
}
}
const user = await prisma.user.update({
where: { id },
data: updateData,

View File

@@ -110,6 +110,61 @@ const TEMPLATES: TemplateDefinition[] = [
{ key: 'SHIFT_STATUS', label: 'Shift Status', description: 'Status of the shift (OPEN, FULL, CANCELLED)', isRequired: true, isConditional: false, sampleValue: 'OPEN', sortOrder: 10 },
],
},
{
key: 'email-verification',
name: 'Email Verification',
description: 'Sent to new users to verify their email address after self-registration',
category: 'SYSTEM',
subjectLine: 'Verify your email — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the user being verified', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'VERIFICATION_URL', label: 'Verification URL', description: 'URL the user clicks to verify their email', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/verify-email?token=abc123', sortOrder: 1 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 2 },
{ key: 'EXPIRY_HOURS', label: 'Expiry Hours', description: 'Number of hours until the verification link expires', isRequired: true, isConditional: false, sampleValue: '24', sortOrder: 3 },
],
},
{
key: 'password-reset',
name: 'Password Reset',
description: 'Sent when a user requests a password reset link',
category: 'SYSTEM',
subjectLine: 'Reset your password — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the user requesting a reset', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'RESET_URL', label: 'Reset URL', description: 'URL the user clicks to reset their password', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/reset-password?token=abc123', sortOrder: 1 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 2 },
],
},
{
key: 'account-approved',
name: 'Account Approved',
description: 'Sent to a user when an admin approves their account after email verification',
category: 'SYSTEM',
subjectLine: 'Account approved — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the approved user', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'LOGIN_URL', label: 'Login URL', description: 'URL to the login page', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/login', sortOrder: 1 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 2 },
],
},
{
key: 'account-pending-approval',
name: 'Account Pending Approval (Admin Notification)',
description: 'Sent to admins when a new user verifies their email and is awaiting approval',
category: 'SYSTEM',
subjectLine: 'New user awaiting approval — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'NEW_USER_NAME', label: 'New User Name', description: 'Name of the new user requesting approval', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'NEW_USER_EMAIL', label: 'New User Email', description: 'Email of the new user', isRequired: true, isConditional: false, sampleValue: 'jane@example.com', sortOrder: 1 },
{ key: 'REGISTERED_AT', label: 'Registration Date', description: 'Date the user registered', isRequired: true, isConditional: false, sampleValue: 'February 16, 2026', sortOrder: 2 },
{ key: 'ADMIN_URL', label: 'Admin URL', description: 'URL to the admin users page filtered to pending approval', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/app/users?status=PENDING_APPROVAL', sortOrder: 3 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 4 },
],
},
];
async function seedEmailTemplates() {
@@ -128,6 +183,7 @@ async function seedEmailTemplates() {
password: 'not-used-for-login',
name: 'System',
role: 'SUPER_ADMIN',
roles: JSON.parse(JSON.stringify(['SUPER_ADMIN'])),
status: 'ACTIVE',
},
});

View File

@@ -16,6 +16,8 @@ import { campaignEmailsPublicRouter, campaignEmailsAdminRouter } from './modules
import { emailQueueRouter } from './modules/influence/email-queue/email-queue.routes';
import { representativesRouter } from './modules/influence/representatives/representatives.routes';
import { campaignPublicRouter } from './modules/influence/campaigns/campaigns-public.routes';
import { campaignUserRouter } from './modules/influence/campaigns/campaigns-user.routes';
import { campaignModerationRouter } from './modules/influence/campaigns/campaigns-moderation.routes';
import { responseCampaignPublicRouter, responsesPublicRouter, responsesAdminRouter } from './modules/influence/responses/responses.routes';
import { locationsAdminRouter, locationsPublicRouter } from './modules/map/locations/locations.routes';
import { bulkGeocodeRouter } from './modules/map/locations/bulk-geocode.routes';
@@ -36,8 +38,10 @@ import { trackingVolunteerRouter, trackingAdminRouter } from './modules/map/trac
import { geocodingRouter } from './modules/map/geocoding/geocoding.routes';
import { pangolinRouter } from './modules/pangolin/pangolin.routes';
import { narImportRouter } from './modules/map/locations/nar-import.routes';
import { areaImportRouter } from './modules/map/locations/area-import.routes';
import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes';
import { observabilityRouter } from './modules/observability/observability.routes';
import { dashboardRouter } from './modules/dashboard/dashboard.routes';
import { initEncryption } from './utils/crypto';
import { emailService } from './services/email.service';
import { emailQueueService } from './services/email-queue.service';
@@ -46,6 +50,8 @@ import { startProxy, stopProxy } from './services/listmonk-proxy.service';
import { pagesService } from './modules/pages/pages.service';
import { canvassService } from './modules/map/canvass/canvass.service';
import { trackingService } from './modules/map/tracking/tracking.service';
import { verificationTokenService } from './services/verification-token.service';
import { passwordResetTokenService } from './services/password-reset-token.service';
const app = express();
@@ -113,6 +119,8 @@ app.use('/api/users', usersRouter);
app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth)
app.use('/api/campaigns', responseCampaignPublicRouter); // Public response routes (no auth)
app.use('/api/campaigns', campaignEmailsPublicRouter); // Public email routes (no auth)
app.use('/api/campaigns', campaignUserRouter); // User campaign submission (auth, non-temp)
app.use('/api/campaigns', campaignModerationRouter); // Moderation queue (admin auth)
app.use('/api/campaigns', campaignEmailsAdminRouter); // Admin email routes (auth required)
app.use('/api/campaigns', campaignsRouter); // Admin campaign CRUD (auth required)
app.use('/api/responses', responsesPublicRouter); // Public response actions (no auth)
@@ -123,6 +131,7 @@ app.use('/api/map/locations', locationsPublicRouter); // Public map data (no
app.use('/api/map/locations', locationsAdminRouter); // Admin location CRUD (auth required)
app.use('/api/map/locations/bulk-geocode', bulkGeocodeRouter); // Bulk re-geocoding (admin auth required)
app.use('/api/map/nar-import', narImportRouter); // NAR server-side import (MAP_ADMIN+)
app.use('/api/map/area-import', areaImportRouter); // Area import from multiple sources (MAP_ADMIN+)
app.use('/api/map/cuts', cutsPublicRouter); // Public cut polygons (no auth)
app.use('/api/map/cuts', cutsAdminRouter); // Admin cut CRUD (auth required)
app.use('/api/map/shifts', shiftsPublicRouter); // Public shift listing + signup (no auth)
@@ -146,6 +155,7 @@ app.use('/api/map/tracking', trackingAdminRouter); // Admin GPS tracking
app.use('/api/settings', siteSettingsRouter); // Site settings (public GET, SUPER_ADMIN PUT)
app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles)
// --- Error Handler (must be last) ---
app.use(errorHandler);
@@ -170,6 +180,14 @@ async function start() {
geocodeQueueService.startWorker();
startProxy();
// Clean expired verification/reset tokens on startup + hourly
verificationTokenService.cleanupExpiredTokens().catch(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
setInterval(() => {
verificationTokenService.cleanupExpiredTokens().catch(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
}, 60 * 60 * 1000);
// Close abandoned canvass sessions on startup + hourly
canvassService.closeAbandonedSessions().catch(() => {});
setInterval(() => {

View File

@@ -439,6 +439,261 @@ class EmailService {
});
}
/** Check whether production SMTP is configured (not mailhog test mode) */
async isSmtpConfigured(): Promise<boolean> {
try {
const settings = await siteSettingsService.get();
return settings.smtpActiveProvider === 'production' && !!settings.smtpHost;
} catch {
return env.SMTP_HOST !== 'mailhog-changemaker' && !!env.SMTP_HOST;
}
}
async sendVerificationEmail(options: {
recipientEmail: string;
recipientName: string;
verificationUrl: string;
}): Promise<SendEmailResult> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
RECIPIENT_NAME: options.recipientName || 'there',
VERIFICATION_URL: options.verificationUrl,
ORGANIZATION_NAME: orgName,
EXPIRY_HOURS: '24',
};
const dbTemplate = await this.loadTemplateFromDatabase('email-verification');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('email-verification', 'html');
const txtTemplate = this.loadTemplate('email-verification', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Verify your email — ${orgName}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendPasswordResetEmail(options: {
recipientEmail: string;
recipientName: string;
resetUrl: string;
}): Promise<SendEmailResult> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
RECIPIENT_NAME: options.recipientName || 'there',
RESET_URL: options.resetUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('password-reset');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('password-reset', 'html');
const txtTemplate = this.loadTemplate('password-reset', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Reset your password — ${orgName}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendAccountApprovedEmail(options: {
recipientEmail: string;
recipientName: string;
loginUrl: string;
}): Promise<SendEmailResult> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
RECIPIENT_NAME: options.recipientName || 'there',
LOGIN_URL: options.loginUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('account-approved');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('account-approved', 'html');
const txtTemplate = this.loadTemplate('account-approved', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Account approved — ${orgName}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendPendingApprovalNotification(options: {
adminEmails: string[];
newUserEmail: string;
newUserName: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const vars: Record<string, string> = {
NEW_USER_NAME: options.newUserName || '(not provided)',
NEW_USER_EMAIL: options.newUserEmail,
REGISTERED_AT: new Date().toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric' }),
ADMIN_URL: `${adminUrl}/app/users?status=PENDING_APPROVAL`,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('account-pending-approval');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('account-pending-approval', 'html');
const txtTemplate = this.loadTemplate('account-pending-approval', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `New user awaiting approval — ${orgName}`;
}
for (const email of options.adminEmails) {
await this.sendEmail({
to: email,
subject,
html,
text,
});
}
}
async sendShiftSignupConfirmation(options: {
recipientEmail: string;
recipientName: string;
shiftTitle: string;
shiftDate: string;
shiftTime: string;
shiftLocation: string;
isNewUser: boolean;
tempPassword?: string;
loginUrl: string;
}): Promise<SendEmailResult> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
USER_NAME: options.recipientName,
USER_EMAIL: options.recipientEmail,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
SHIFT_TIME: options.shiftTime,
SHIFT_LOCATION: options.shiftLocation,
IS_NEW_USER: options.isNewUser ? 'true' : '',
TEMP_PASSWORD: options.tempPassword || '',
LOGIN_URL: options.loginUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('shift-signup-confirmation');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = this.loadTemplate('shift-signup-confirmation', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Signup Confirmed — ${options.shiftTitle}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendShiftDetailsEmail(options: {
recipientEmail: string;
recipientName: string;
shiftTitle: string;
shiftDate: string;
shiftStartTime: string;
shiftEndTime: string;
shiftLocation: string;
shiftDescription: string;
currentVolunteers: number;
maxVolunteers: number;
shiftStatus: string;
}): Promise<SendEmailResult> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
USER_NAME: options.recipientName,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
SHIFT_START_TIME: options.shiftStartTime,
SHIFT_END_TIME: options.shiftEndTime,
SHIFT_LOCATION: options.shiftLocation,
SHIFT_DESCRIPTION: options.shiftDescription,
CURRENT_VOLUNTEERS: options.currentVolunteers.toString(),
MAX_VOLUNTEERS: options.maxVolunteers.toString(),
SHIFT_STATUS: options.shiftStatus,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('shift-details');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('shift-details', 'html');
const txtTemplate = this.loadTemplate('shift-details', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Shift Details — ${options.shiftTitle}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendResponseVerification(options: {
recipientEmail: string;
campaignTitle: string;

View File

@@ -85,8 +85,10 @@ export function startProxy(): http.Server {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
if (payload.role !== UserRole.SUPER_ADMIN) {
const payloadRoles = payload.roles || [payload.role];
if (!payloadRoles.includes(UserRole.SUPER_ADMIN)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden — SUPER_ADMIN role required');
return;

View File

@@ -0,0 +1,58 @@
import crypto from 'crypto';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
export const passwordResetTokenService = {
async createToken(userId: string): Promise<string> {
// Delete any existing tokens for this user
await prisma.passwordResetToken.deleteMany({ where: { userId } });
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await prisma.passwordResetToken.create({
data: { userId, token, expiresAt },
});
logger.info(`Password reset token created for user ${userId}`);
return token;
},
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
const record = await prisma.passwordResetToken.findUnique({ where: { token } });
if (!record) {
return { valid: false, error: 'Invalid or expired reset token' };
}
if (record.expiresAt < new Date()) {
await prisma.passwordResetToken.delete({ where: { id: record.id } });
return { valid: false, error: 'Reset token has expired' };
}
if (record.usedAt) {
return { valid: false, error: 'Reset token has already been used' };
}
return { valid: true, userId: record.userId };
},
async markTokenUsed(token: string): Promise<void> {
await prisma.passwordResetToken.update({
where: { token },
data: { usedAt: new Date() },
});
},
async cleanupExpiredTokens(): Promise<number> {
const result = await prisma.passwordResetToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
if (result.count > 0) {
logger.info(`Cleaned up ${result.count} expired password reset tokens`);
}
return result.count;
},
};

View File

@@ -0,0 +1,50 @@
import crypto from 'crypto';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
export const verificationTokenService = {
async createToken(userId: string): Promise<string> {
// Delete any existing tokens for this user
await prisma.emailVerificationToken.deleteMany({ where: { userId } });
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await prisma.emailVerificationToken.create({
data: { userId, token, expiresAt },
});
logger.info(`Verification token created for user ${userId}`);
return token;
},
async verifyToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
const record = await prisma.emailVerificationToken.findUnique({ where: { token } });
if (!record) {
return { valid: false, error: 'Invalid or expired verification token' };
}
if (record.expiresAt < new Date()) {
await prisma.emailVerificationToken.delete({ where: { id: record.id } });
return { valid: false, error: 'Verification token has expired' };
}
// Delete the token after successful verification
await prisma.emailVerificationToken.delete({ where: { id: record.id } });
return { valid: true, userId: record.userId };
},
async cleanupExpiredTokens(): Promise<number> {
const result = await prisma.emailVerificationToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
if (result.count > 0) {
logger.info(`Cleaned up ${result.count} expired verification tokens`);
}
return result.count;
},
};

View File

@@ -0,0 +1,482 @@
import { Queue, Worker, type Job } from 'bullmq';
import { spawn, type ChildProcess } from 'child_process';
import { writeFile, readFile, unlink, mkdir } from 'fs/promises';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { tmpdir } from 'os';
import Redis from 'ioredis';
import { env } from '../config/env';
import { prisma } from '../config/database';
import { extractVideoMetadata, validateVideoFile } from '../modules/media/services/ffprobe.service';
import { ThumbnailService } from '../modules/media/services/thumbnail.service';
import { logger } from '../utils/logger';
interface VideoFetchJobData {
urls: string[];
submittedByUserId: string;
}
interface FetchResult {
url: string;
success: boolean;
videoId?: number;
title?: string;
error?: string;
}
interface FetchJobResult {
results: FetchResult[];
totalUrls: number;
successCount: number;
failCount: number;
}
// Shell metacharacters that could enable command injection
const SHELL_METACHAR_REGEX = /[`$;|&<>(){}[\]\\!#]/;
/**
* Sanitize and validate a URL for safe shell usage.
* Returns the URL if valid, or null if invalid or contains shell metacharacters.
*/
function sanitizeUrl(url: string): string | null {
if (!url || typeof url !== 'string') return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Validate URL structure
try {
const parsed = new URL(trimmed);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
} catch {
return null;
}
// Reject shell metacharacters
if (SHELL_METACHAR_REGEX.test(trimmed)) return null;
return trimmed;
}
// Active child processes for cancellation
const activeProcesses = new Map<string, ChildProcess>();
class VideoFetchQueueService {
private queue: Queue;
private worker: Worker | null = null;
private redis: Redis | null = null;
constructor() {
this.queue = new Queue('video-fetch', {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 1, // No retries for fetch jobs
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 200 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
});
}
private getRedis(): Redis {
if (!this.redis) {
this.redis = new Redis(env.REDIS_URL);
}
return this.redis;
}
/**
* Append a log line for a job (stored in Redis list with 24h TTL)
*/
private async appendJobLog(jobId: string, line: string): Promise<void> {
const key = `fetch-log:${jobId}`;
const redis = this.getRedis();
await redis.rpush(key, line);
await redis.expire(key, 86400); // 24 hours
// Publish for SSE subscribers
await redis.publish(`fetch-log-stream:${jobId}`, line);
}
/**
* Get accumulated log lines for a job
*/
async getJobLog(jobId: string): Promise<string[]> {
const key = `fetch-log:${jobId}`;
return this.getRedis().lrange(key, 0, -1);
}
/**
* Start the worker to process fetch jobs
*/
startWorker(): void {
this.worker = new Worker(
'video-fetch',
async (job: Job<VideoFetchJobData>) => {
const { urls, submittedByUserId } = job.data;
const jobId = job.id!;
const results: FetchResult[] = [];
logger.info(`Processing video fetch job ${jobId}`, { urlCount: urls.length });
await this.appendJobLog(jobId, `Starting fetch of ${urls.length} URL(s)...`);
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const urlNum = i + 1;
await this.appendJobLog(jobId, `\n--- URL ${urlNum}/${urls.length}: ${url} ---`);
try {
const result = await this.fetchSingleUrl(jobId, url);
results.push({ url, success: true, videoId: result.videoId, title: result.title });
await this.appendJobLog(jobId, `Imported as video #${result.videoId}: ${result.title}`);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
results.push({ url, success: false, error });
await this.appendJobLog(jobId, `FAILED: ${error}`);
}
// Update overall progress
const percent = Math.round(((i + 1) / urls.length) * 100);
await job.updateProgress(percent);
// Publish progress event for SSE
const redis = this.getRedis();
await redis.publish(`fetch-log-stream:${jobId}`, `__PROGRESS__:${percent}`);
}
const successCount = results.filter(r => r.success).length;
const failCount = results.length - successCount;
await this.appendJobLog(jobId, `\n=== Fetch complete: ${successCount} succeeded, ${failCount} failed ===`);
// Publish done event
const redis = this.getRedis();
await redis.publish(`fetch-log-stream:${jobId}`, '__DONE__');
const jobResult: FetchJobResult = {
results,
totalUrls: urls.length,
successCount,
failCount,
};
return jobResult;
},
{
connection: { url: env.REDIS_URL },
concurrency: 2,
}
);
this.worker.on('completed', (job) => {
logger.info(`Video fetch job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Video fetch job ${job?.id} failed: ${err.message}`);
});
logger.info('Video fetch queue worker started');
}
/**
* Fetch a single URL using yt-dlp, extract metadata, and create Video record
*/
private async fetchSingleUrl(
jobId: string,
url: string
): Promise<{ videoId: number; title: string }> {
const inboxDir = '/media/local/inbox';
await mkdir(inboxDir, { recursive: true });
// Create a temp file to capture the output filepath from yt-dlp
const tempFile = join(tmpdir(), `yt-dlp-output-${randomUUID()}.txt`);
return new Promise(async (resolve, reject) => {
const args = [
'-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
'--merge-output-format', 'mp4',
'-o', join(inboxDir, '%(title)s.%(ext)s'),
'--restrict-filenames',
'--no-overwrites',
'--newline',
'--print-to-file', 'after_move:filepath', tempFile,
url,
];
const child = spawn('yt-dlp', args);
activeProcesses.set(jobId, child);
let stderr = '';
child.stdout.on('data', async (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
await this.appendJobLog(jobId, trimmed);
}
}
});
child.stderr.on('data', async (data) => {
const text = data.toString();
stderr += text;
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
await this.appendJobLog(jobId, `[stderr] ${trimmed}`);
}
}
});
child.on('close', async (code, signal) => {
activeProcesses.delete(jobId);
if (signal === 'SIGTERM' || signal === 'SIGKILL') {
// Clean up temp file
try { await unlink(tempFile); } catch {}
reject(new Error('Fetch cancelled'));
return;
}
if (code !== 0) {
try { await unlink(tempFile); } catch {}
reject(new Error(`yt-dlp exited with code ${code}: ${stderr.slice(0, 500)}`));
return;
}
try {
// Read the output file path
const outputPath = (await readFile(tempFile, 'utf-8')).trim();
await unlink(tempFile);
if (!outputPath) {
reject(new Error('yt-dlp did not produce an output file path'));
return;
}
await this.appendJobLog(jobId, `Downloaded to: ${outputPath}`);
// Validate the video file
await this.appendJobLog(jobId, 'Validating video file...');
const isValid = await validateVideoFile(outputPath);
if (!isValid) {
reject(new Error('Downloaded file is invalid or corrupted'));
return;
}
// Extract metadata
await this.appendJobLog(jobId, 'Extracting metadata...');
const metadata = await extractVideoMetadata(outputPath);
// Derive filename and title from the output path
const filename = outputPath.split('/').pop() || 'unknown.mp4';
const title = filename.replace(/\.[^.]+$/, '').replace(/_/g, ' ');
// Create Video record (same pattern as upload.routes.ts)
const video = await prisma.video.create({
data: {
path: outputPath,
filename,
originalFilename: filename,
title,
durationSeconds: metadata.durationSeconds,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
quality: metadata.quality,
hasAudio: metadata.hasAudio,
fileSize: metadata.fileSize,
directoryType: 'inbox',
isValid: true,
},
});
await this.appendJobLog(jobId, `Created video record #${video.id}`);
// Generate thumbnail
try {
const thumbnailPath = await ThumbnailService.generateThumbnail({
videoPath: outputPath,
videoId: video.id,
duration: metadata.durationSeconds,
orientation: metadata.orientation,
});
await prisma.video.update({
where: { id: video.id },
data: { thumbnailPath },
});
await this.appendJobLog(jobId, 'Thumbnail generated');
} catch (thumbnailErr) {
await this.appendJobLog(jobId, `Thumbnail generation failed (non-fatal): ${thumbnailErr instanceof Error ? thumbnailErr.message : 'Unknown error'}`);
}
resolve({ videoId: video.id, title });
} catch (err) {
reject(err);
}
});
child.on('error', async (err) => {
activeProcesses.delete(jobId);
try { await unlink(tempFile); } catch {}
reject(new Error(`Failed to spawn yt-dlp: ${err.message}`));
});
});
}
/**
* Submit a new fetch job
*/
async submitFetch(
urls: string[],
userId: string
): Promise<{ jobId: string; urlCount: number; sanitizedUrls: string[] }> {
// Sanitize URLs
const sanitizedUrls = urls
.map(u => sanitizeUrl(u))
.filter((u): u is string => u !== null);
if (sanitizedUrls.length === 0) {
throw new Error('No valid URLs provided. URLs must be valid HTTP(S) URLs without shell metacharacters.');
}
if (sanitizedUrls.length > 20) {
throw new Error('Maximum 20 URLs per submission');
}
const job = await this.queue.add('fetch', {
urls: sanitizedUrls,
submittedByUserId: userId,
});
logger.info(`Submitted video fetch job ${job.id}`, {
urlCount: sanitizedUrls.length,
userId,
});
return {
jobId: job.id!,
urlCount: sanitizedUrls.length,
sanitizedUrls,
};
}
/**
* Get a single job by ID
*/
async getJob(jobId: string) {
const job = await this.queue.getJob(jobId);
if (!job) return null;
const state = await job.getState();
const progress = job.progress as number;
return {
id: job.id,
data: job.data,
state,
progress,
returnvalue: job.returnvalue as FetchJobResult | null,
failedReason: job.failedReason,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
processedOn: job.processedOn,
};
}
/**
* Get recent fetch jobs
*/
async getRecentJobs(limit: number = 20) {
const jobs = await this.queue.getJobs(
['active', 'waiting', 'delayed', 'completed', 'failed'],
0,
limit
);
const results = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return {
id: job.id,
urls: job.data.urls,
urlCount: job.data.urls.length,
state,
progress: job.progress as number,
returnvalue: job.returnvalue as FetchJobResult | null,
failedReason: job.failedReason,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
processedOn: job.processedOn,
};
})
);
// Sort by timestamp descending (newest first)
return results.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
}
/**
* Cancel a running or waiting job
*/
async cancelJob(jobId: string): Promise<boolean> {
const job = await this.queue.getJob(jobId);
if (!job) return false;
const state = await job.getState();
if (state === 'active') {
// Kill the yt-dlp process
const proc = activeProcesses.get(jobId);
if (proc) {
proc.kill('SIGTERM');
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch {}
}, 3000);
}
// Move to failed
await job.moveToFailed(new Error('Cancelled by user'), job.id!, true);
await this.appendJobLog(jobId, '\n=== FETCH CANCELLED BY USER ===');
} else if (state === 'waiting' || state === 'delayed') {
await job.remove();
} else {
return false; // Can't cancel completed/failed jobs
}
return true;
}
/**
* Subscribe to log stream for a job (returns Redis subscriber)
*/
createLogSubscriber(jobId: string): Redis {
const sub = new Redis(env.REDIS_URL);
sub.subscribe(`fetch-log-stream:${jobId}`);
return sub;
}
/**
* Close queue and worker
*/
async close(): Promise<void> {
// Kill any active processes
for (const [id, proc] of activeProcesses) {
try { proc.kill('SIGKILL'); } catch {}
activeProcesses.delete(id);
}
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
if (this.redis) {
await this.redis.quit();
}
logger.info('Video fetch queue closed');
}
}
export const videoFetchQueueService = new VideoFetchQueueService();

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Account Approved — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #16a34a;
padding-bottom: 20px;
}
.logo {
color: #16a34a;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #16a34a;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #16a34a, #15803d);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Account Approved</p>
</div>
<div class="content">
<p>Hi {{RECIPIENT_NAME}},</p>
<p>Your account has been approved. You can now log in and start using the platform.</p>
<div class="btn-container">
<a href="{{LOGIN_URL}}" class="btn">Log In Now</a>
</div>
</div>
<div class="footer">
<p>This email was sent by <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
{{ORGANIZATION_NAME}} — Account Approved
Hi {{RECIPIENT_NAME}},
Your account has been approved. You can now log in and start using the platform.
Log in: {{LOGIN_URL}}
— {{ORGANIZATION_NAME}}

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New User Awaiting Approval — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #d97706;
padding-bottom: 20px;
}
.logo {
color: #d97706;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #d97706;
}
.info-section {
background-color: #fffbeb;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fde68a;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #92400e;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #d97706, #b45309);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">New User Awaiting Approval</p>
</div>
<div class="content">
<p>A new user has verified their email and is waiting for admin approval:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Name:</span> {{NEW_USER_NAME}}
</div>
<div class="info-item">
<span class="info-label">Email:</span> {{NEW_USER_EMAIL}}
</div>
<div class="info-item">
<span class="info-label">Registered:</span> {{REGISTERED_AT}}
</div>
</div>
<div class="btn-container">
<a href="{{ADMIN_URL}}" class="btn">Review in Admin Panel</a>
</div>
</div>
<div class="footer">
<p>This is an admin notification from <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,11 @@
{{ORGANIZATION_NAME}} — New User Awaiting Approval
A new user has verified their email and is waiting for admin approval:
Name: {{NEW_USER_NAME}}
Email: {{NEW_USER_EMAIL}}
Registered: {{REGISTERED_AT}}
Review in admin panel: {{ADMIN_URL}}
— {{ORGANIZATION_NAME}}

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verify Your Email — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #2563eb;
padding-bottom: 20px;
}
.logo {
color: #2563eb;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #2563eb;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.expiry-notice {
background-color: #fef3c7;
padding: 12px 16px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fde68a;
font-size: 14px;
color: #92400e;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Email Verification</p>
</div>
<div class="content">
<p>Hi {{RECIPIENT_NAME}},</p>
<p>Thank you for creating an account. Please verify your email address by clicking the button below:</p>
<div class="btn-container">
<a href="{{VERIFICATION_URL}}" class="btn">Verify Email Address</a>
</div>
<div class="expiry-notice">
This link expires in {{EXPIRY_HOURS}} hours. If you did not create this account, you can safely ignore this email.
</div>
<p style="font-size: 13px; color: #6c757d;">
If the button doesn't work, copy and paste this URL into your browser:<br>
<span style="word-break: break-all;">{{VERIFICATION_URL}}</span>
</p>
</div>
<div class="footer">
<p>This email was sent by <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{{ORGANIZATION_NAME}} — Email Verification
Hi {{RECIPIENT_NAME}},
Thank you for creating an account. Please verify your email address by visiting the link below:
{{VERIFICATION_URL}}
This link expires in {{EXPIRY_HOURS}} hours.
If you did not create this account, you can safely ignore this email.
— {{ORGANIZATION_NAME}}

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Password Reset — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #2563eb;
padding-bottom: 20px;
}
.logo {
color: #2563eb;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #2563eb;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.expiry-notice {
background-color: #fef3c7;
padding: 12px 16px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fde68a;
font-size: 14px;
color: #92400e;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Password Reset</p>
</div>
<div class="content">
<p>Hi {{RECIPIENT_NAME}},</p>
<p>We received a request to reset your password. Click the button below to set a new password:</p>
<div class="btn-container">
<a href="{{RESET_URL}}" class="btn">Reset Password</a>
</div>
<div class="expiry-notice">
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</div>
<p style="font-size: 13px; color: #6c757d;">
If the button doesn't work, copy and paste this URL into your browser:<br>
<span style="word-break: break-all;">{{RESET_URL}}</span>
</p>
</div>
<div class="footer">
<p>This email was sent by <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,11 @@
{{ORGANIZATION_NAME}} — Password Reset
Hi {{RECIPIENT_NAME}},
We received a request to reset your password. Visit the link below to set a new password:
{{RESET_URL}}
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
— {{ORGANIZATION_NAME}}

View File

@@ -7,6 +7,7 @@ declare global {
id: string;
email: string;
role: UserRole;
roles: UserRole[];
};
}
}

View File

@@ -40,7 +40,7 @@ export function validatePromQLQuery(query: string): void {
// Basic structure validation: queries should contain alphanumeric, underscores, colons, and safe operators
// This is a permissive check to catch obvious malicious input
const safePattern = /^[a-zA-Z0-9_:(){}\[\],.+\-*/<>=!\s"'%]+$/;
const safePattern = /^[a-zA-Z0-9_:(){}\[\],.+\-*/<>=!~\s"'%]+$/;
if (!safePattern.test(query)) {
throw new Error('Invalid PromQL query: contains unsafe characters');
}

39
api/src/utils/roles.ts Normal file
View File

@@ -0,0 +1,39 @@
import { UserRole } from '@prisma/client';
const ROLE_PRIORITY: Record<string, number> = {
SUPER_ADMIN: 5,
INFLUENCE_ADMIN: 4,
MAP_ADMIN: 3,
USER: 2,
TEMP: 1,
};
export const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
/** Check if the user has any of the specified roles */
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {
const userRoles = getUserRoles(user);
return userRoles.some(r => roles.includes(r));
}
/** Check if user has any admin role */
export function isAdmin(user: { roles?: unknown; role?: UserRole }): boolean {
return hasAnyRole(user, ADMIN_ROLES);
}
/** Get the primary (highest-priority) role from a roles array */
export function getPrimaryRole(roles: UserRole[]): UserRole {
if (roles.length === 0) return UserRole.USER;
return roles.reduce((highest, current) =>
(ROLE_PRIORITY[current] || 0) > (ROLE_PRIORITY[highest] || 0) ? current : highest
);
}
/** Safely extract UserRole[] from a user object (handles old single-role and new multi-role) */
export function getUserRoles(user: { roles?: unknown; role?: UserRole }): UserRole[] {
if (Array.isArray(user.roles) && user.roles.length > 0) {
return user.roles as UserRole[];
}
if (user.role) return [user.role];
return [UserRole.USER];
}

View File

@@ -99,6 +99,41 @@ export function haversineDistance(
* Calculate the centroid of an array of [lng, lat] coordinate pairs.
* Returns { lat, lng }.
*/
/**
* Calculate a bounding box from a map center point and zoom level.
* Uses Web Mercator tile math to derive the geographic extent visible
* on a map of the given pixel dimensions at the given zoom.
* Default viewport: 1024x768 pixels.
*/
export function boundsFromCenterZoom(
lat: number,
lng: number,
zoom: number,
widthPx: number = 1024,
heightPx: number = 768,
): { minLat: number; maxLat: number; minLng: number; maxLng: number } {
// Meters per pixel at a given zoom level at the equator
const metersPerPixelAtEquator = 156543.03392;
const metersPerPixel = metersPerPixelAtEquator * Math.cos((lat * Math.PI) / 180) / Math.pow(2, zoom);
const halfWidthMeters = (widthPx / 2) * metersPerPixel;
const halfHeightMeters = (heightPx / 2) * metersPerPixel;
// Convert meters offset to degrees
const latDegPerMeter = 1 / 111320;
const lngDegPerMeter = 1 / (111320 * Math.cos((lat * Math.PI) / 180));
const latOffset = halfHeightMeters * latDegPerMeter;
const lngOffset = halfWidthMeters * lngDegPerMeter;
return {
minLat: lat - latOffset,
maxLat: lat + latOffset,
minLng: lng - lngOffset,
maxLng: lng + lngOffset,
};
}
export function calculateCentroid(coordinates: number[][]): { lat: number; lng: number } {
let sumLat = 0;
let sumLng = 0;