Bunch of stuff again
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
43
api/src/modules/auth/auth.rate-limits.ts
Normal file
43
api/src/modules/auth/auth.rate-limits.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
124
api/src/modules/dashboard/dashboard.routes.ts
Normal file
124
api/src/modules/dashboard/dashboard.routes.ts
Normal 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;
|
||||
621
api/src/modules/dashboard/dashboard.service.ts
Normal file
621
api/src/modules/dashboard/dashboard.service.ts
Normal 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}¤t=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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
77
api/src/modules/influence/campaigns/campaigns-user.routes.ts
Normal file
77
api/src/modules/influence/campaigns/campaigns-user.routes.ts
Normal 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 };
|
||||
@@ -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>;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
89
api/src/modules/map/locations/area-import.routes.ts
Normal file
89
api/src/modules/map/locations/area-import.routes.ts
Normal 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 };
|
||||
57
api/src/modules/map/locations/area-import.schemas.ts
Normal file
57
api/src/modules/map/locations/area-import.schemas.ts
Normal 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>;
|
||||
671
api/src/modules/map/locations/area-import.service.ts
Normal file
671
api/src/modules/map/locations/area-import.service.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
211
api/src/modules/map/locations/overpass.service.ts
Normal file
211
api/src/modules/map/locations/overpass.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
117
api/src/modules/media/routes/chat-notifications.routes.ts
Normal file
117
api/src/modules/media/routes/chat-notifications.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
161
api/src/modules/media/routes/chat-threads.routes.ts
Normal file
161
api/src/modules/media/routes/chat-threads.routes.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
505
api/src/modules/media/routes/comment-admin.routes.ts
Normal file
505
api/src/modules/media/routes/comment-admin.routes.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
198
api/src/modules/media/routes/fetch.routes.ts
Normal file
198
api/src/modules/media/routes/fetch.routes.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
386
api/src/modules/media/routes/playlists-admin.routes.ts
Normal file
386
api/src/modules/media/routes/playlists-admin.routes.ts
Normal 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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
376
api/src/modules/media/routes/playlists-public.routes.ts
Normal file
376
api/src/modules/media/routes/playlists-public.routes.ts
Normal 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
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
450
api/src/modules/media/routes/playlists-user.routes.ts
Normal file
450
api/src/modules/media/routes/playlists-user.routes.ts
Normal 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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
172
api/src/modules/media/routes/shorts.routes.ts
Normal file
172
api/src/modules/media/routes/shorts.routes.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
112
api/src/modules/media/routes/upvote.routes.ts
Normal file
112
api/src/modules/media/routes/upvote.routes.ts
Normal 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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
405
api/src/modules/media/routes/user-profile.routes.ts
Normal file
405
api/src/modules/media/routes/user-profile.routes.ts
Normal 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' });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
95
api/src/modules/media/services/word-filter.service.ts
Normal file
95
api/src/modules/media/services/word-filter.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
api/src/services/password-reset-token.service.ts
Normal file
58
api/src/services/password-reset-token.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
50
api/src/services/verification-token.service.ts
Normal file
50
api/src/services/verification-token.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
482
api/src/services/video-fetch-queue.service.ts
Normal file
482
api/src/services/video-fetch-queue.service.ts
Normal 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();
|
||||
84
api/src/templates/email/account-approved.html
Normal file
84
api/src/templates/email/account-approved.html
Normal 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>
|
||||
9
api/src/templates/email/account-approved.txt
Normal file
9
api/src/templates/email/account-approved.txt
Normal 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}}
|
||||
109
api/src/templates/email/account-pending-approval.html
Normal file
109
api/src/templates/email/account-pending-approval.html
Normal 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>
|
||||
11
api/src/templates/email/account-pending-approval.txt
Normal file
11
api/src/templates/email/account-pending-approval.txt
Normal 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}}
|
||||
102
api/src/templates/email/email-verification.html
Normal file
102
api/src/templates/email/email-verification.html
Normal 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>
|
||||
13
api/src/templates/email/email-verification.txt
Normal file
13
api/src/templates/email/email-verification.txt
Normal 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}}
|
||||
102
api/src/templates/email/password-reset.html
Normal file
102
api/src/templates/email/password-reset.html
Normal 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>
|
||||
11
api/src/templates/email/password-reset.txt
Normal file
11
api/src/templates/email/password-reset.txt
Normal 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}}
|
||||
1
api/src/types/express.d.ts
vendored
1
api/src/types/express.d.ts
vendored
@@ -7,6 +7,7 @@ declare global {
|
||||
id: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
roles: UserRole[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
39
api/src/utils/roles.ts
Normal 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];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user