A whole bunch of stuff agian lol I promise to track more closely when we get to more stable state - like end of feb

This commit is contained in:
2026-02-19 09:41:27 -07:00
parent 1a1f12c45b
commit 435fb8150c
71 changed files with 8498 additions and 652 deletions

View File

@@ -25,6 +25,11 @@ 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';
import { photosRoutes } from './modules/media/routes/photos.routes';
import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes';
import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes';
import { photosPublicRoutes } from './modules/media/routes/photos-public.routes';
import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@@ -141,6 +146,13 @@ const start = async () => {
await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' });
await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' });
// Photo gallery routes
await fastify.register(photosRoutes, { prefix: '/api/photos' });
await fastify.register(photoUploadRoutes, { prefix: '/api/photos' });
await fastify.register(photoAlbumsRoutes, { prefix: '/api/albums' });
await fastify.register(photosPublicRoutes, { prefix: '/api' });
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
const port = env.MEDIA_API_PORT;
const host = '0.0.0.0';

View File

@@ -156,6 +156,23 @@ export const adTrackingRateLimit = rateLimit({
},
});
export const quickJoinRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:quick-join:',
}),
message: {
error: {
message: 'Too many join attempts, please try again later',
code: 'QUICK_JOIN_RATE_LIMIT_EXCEEDED',
},
},
});
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Reduced from 20 to prevent brute force attacks

View File

@@ -18,7 +18,7 @@ interface TokenPayload {
roles: UserRole[];
}
interface TokenPair {
export interface TokenPair {
accessToken: string;
refreshToken: string;
}

View File

@@ -13,6 +13,10 @@ import {
getConnectivity,
getTodayEvents,
getChatSummary,
getTopVideos,
getRecentComments,
getUpcomingShifts,
getRecentSignups,
} from './dashboard.service';
const router = Router();
@@ -168,4 +172,44 @@ router.get('/chat-summary', async (_req: Request, res: Response, next: NextFunct
}
});
// GET /api/dashboard/upcoming-shifts — next 5 shifts
router.get('/upcoming-shifts', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getUpcomingShifts();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/recent-signups — latest 8 shift signups
router.get('/recent-signups', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getRecentSignups();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/top-videos — top 5 videos by view count (if media enabled)
router.get('/top-videos', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getTopVideos();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/recent-comments — latest 8 visible comments (if media enabled)
router.get('/recent-comments', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await getRecentComments();
res.json(result);
} catch (err) {
next(err);
}
});
export const dashboardRouter = router;

View File

@@ -10,6 +10,7 @@ import { isServiceOnline } from '../../utils/health-check';
import { listmonkClient } from '../../services/listmonk.client';
import { gancioClient } from '../../services/gancio.client';
import { rocketchatClient } from '../../services/rocketchat.client';
import { emailService } from '../../services/email.service';
import { logger } from '../../utils/logger';
// --- Types ---
@@ -418,7 +419,7 @@ export interface ConnectivityStatus {
export async function getConnectivity(): Promise<ConnectivityStatus> {
const [smtp, listmonk, rocketchat, gancio] = await Promise.all([
isServiceOnline(`${env.SMTP_HOST}`, 3000).catch(() => false),
emailService.testConnection().catch(() => false),
listmonkClient.checkHealth().catch(() => false),
isServiceOnline(env.ROCKETCHAT_URL || '', 3000).catch(() => false),
gancioClient.isAvailable().catch(() => false),
@@ -860,6 +861,243 @@ export async function getTodayEvents(): Promise<TodayEventsResult> {
}
}
// --- Upcoming Shifts ---
export interface UpcomingShiftItem {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: string;
cutName: string | null;
}
export interface UpcomingShiftsResult {
shifts: UpcomingShiftItem[];
total: number;
}
export async function getUpcomingShifts(): Promise<UpcomingShiftsResult> {
try {
const now = new Date();
const [shifts, total] = await Promise.all([
prisma.shift.findMany({
where: { date: { gte: now }, status: { not: 'CANCELLED' } },
orderBy: { date: 'asc' },
take: 5,
select: {
id: true,
title: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
cut: { select: { name: true } },
},
}),
prisma.shift.count({ where: { date: { gte: now }, status: { not: 'CANCELLED' } } }),
]);
return {
shifts: shifts.map(s => ({
id: s.id,
title: s.title,
date: s.date.toISOString(),
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
maxVolunteers: s.maxVolunteers,
currentVolunteers: s.currentVolunteers,
status: s.status,
cutName: s.cut?.name || null,
})),
total,
};
} catch (err) {
logger.debug('Failed to fetch upcoming shifts', err);
return { shifts: [], total: 0 };
}
}
// --- Recent Shift Signups ---
export interface RecentSignupItem {
id: string;
userName: string | null;
userEmail: string;
shiftTitle: string | null;
shiftDate: string | null;
signupDate: string;
signupSource: string;
}
export interface RecentSignupsResult {
signups: RecentSignupItem[];
total: number;
}
export async function getRecentSignups(): Promise<RecentSignupsResult> {
try {
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); // last 14 days
const [signups, total] = await Promise.all([
prisma.shiftSignup.findMany({
where: { signupDate: { gte: since }, status: 'CONFIRMED' },
orderBy: { signupDate: 'desc' },
take: 8,
select: {
id: true,
userName: true,
userEmail: true,
shiftTitle: true,
signupDate: true,
signupSource: true,
shift: { select: { date: true } },
},
}),
prisma.shiftSignup.count({ where: { signupDate: { gte: since }, status: 'CONFIRMED' } }),
]);
return {
signups: signups.map(s => ({
id: s.id,
userName: s.userName,
userEmail: s.userEmail,
shiftTitle: s.shiftTitle,
shiftDate: s.shift.date.toISOString(),
signupDate: s.signupDate.toISOString(),
signupSource: s.signupSource,
})),
total,
};
} catch (err) {
logger.debug('Failed to fetch recent signups', err);
return { signups: [], total: 0 };
}
}
// --- Top Videos (Media) ---
export interface TopVideoItem {
id: number;
title: string | null;
filename: string;
viewCount: number;
commentCount: number;
upvoteCount: number;
durationSeconds: number | null;
isPublished: boolean;
}
export interface TopVideosResult {
enabled: boolean;
videos: TopVideoItem[];
}
export async function getTopVideos(): Promise<TopVideosResult> {
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
return { enabled: false, videos: [] };
}
try {
const videos = await prisma.video.findMany({
select: {
id: true,
title: true,
filename: true,
viewCount: true,
commentCount: true,
upvoteCount: true,
durationSeconds: true,
isPublished: true,
},
orderBy: { viewCount: 'desc' },
take: 5,
});
return {
enabled: true,
videos: videos.map(v => ({
id: v.id,
title: v.title,
filename: v.filename,
viewCount: v.viewCount,
commentCount: v.commentCount,
upvoteCount: v.upvoteCount,
durationSeconds: v.durationSeconds,
isPublished: v.isPublished,
})),
};
} catch (err) {
logger.debug('Failed to fetch top videos', err);
return { enabled: true, videos: [] };
}
}
// --- Recent Comments (Media) ---
export interface RecentCommentItem {
id: number;
content: string;
videoId: number;
videoTitle: string | null;
videoFilename: string;
authorName: string | null;
safetyStatus: string | null;
createdAt: string;
}
export interface RecentCommentsResult {
enabled: boolean;
comments: RecentCommentItem[];
pendingCount: number;
}
export async function getRecentComments(): Promise<RecentCommentsResult> {
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
return { enabled: false, comments: [], pendingCount: 0 };
}
try {
const [comments, pendingCount] = await Promise.all([
prisma.comment.findMany({
where: { isHidden: { not: true } },
include: {
user: { select: { name: true, email: true } },
media: { select: { id: true, title: true, filename: true } },
},
orderBy: { createdAt: 'desc' },
take: 8,
}),
prisma.comment.count({ where: { safetyStatus: 'pending' } }),
]);
return {
enabled: true,
comments: comments.map(c => ({
id: c.id,
content: c.content.slice(0, 200),
videoId: c.media.id,
videoTitle: c.media.title,
videoFilename: c.media.filename,
authorName: c.user?.name || c.user?.email || null,
safetyStatus: c.safetyStatus,
createdAt: c.createdAt.toISOString(),
})),
pendingCount,
};
} catch (err) {
logger.debug('Failed to fetch recent comments', err);
return { enabled: true, comments: [], pendingCount: 0 };
}
}
// --- Chat Summary from Rocket.Chat ---
export interface ChatMessage {

View File

@@ -11,6 +11,7 @@ import {
adminVisitsSchema,
volunteerUpdateLocationSchema,
volunteerCreateLocationSchema,
outcomeTrendsQuerySchema,
} from './canvass.schemas';
import { reverseGeocodeSchema, geocodeAddressSchema } from '../locations/locations.schemas';
import { locationsService } from '../locations/locations.service';
@@ -365,4 +366,18 @@ adminRouter.get(
},
);
// GET /api/map/canvass/trends
adminRouter.get(
'/trends',
validate(outcomeTrendsQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await canvassService.getOutcomeTrends(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { volunteerRouter as canvassVolunteerRouter, adminRouter as canvassAdminRouter };

View File

@@ -101,5 +101,12 @@ export type WalkingRouteInput = z.infer<typeof walkingRouteSchema>;
export type ListMyVisitsInput = z.infer<typeof listMyVisitsSchema>;
export type AdminActivityInput = z.infer<typeof adminActivitySchema>;
export type AdminVisitsInput = z.infer<typeof adminVisitsSchema>;
export const outcomeTrendsQuerySchema = z.object({
granularity: z.enum(['day', 'week']).default('day'),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
});
export type VolunteerUpdateLocationInput = z.infer<typeof volunteerUpdateLocationSchema>;
export type VolunteerCreateLocationInput = z.infer<typeof volunteerCreateLocationSchema>;
export type OutcomeTrendsQueryInput = z.infer<typeof outcomeTrendsQuerySchema>;

View File

@@ -21,6 +21,7 @@ import type {
AdminActivityInput,
AdminVisitsInput,
VolunteerUpdateLocationInput,
OutcomeTrendsQueryInput,
} from './canvass.schemas';
const ADDRESS_SELECT = {
@@ -995,6 +996,56 @@ export const canvassService = {
};
},
async getOutcomeTrends(filters: OutcomeTrendsQueryInput) {
const { granularity } = filters;
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date();
const dateFrom = filters.dateFrom
? new Date(filters.dateFrom)
: new Date(dateTo.getTime() - 30 * 24 * 60 * 60 * 1000);
// Ensure dateTo covers end of day
const dateToEnd = new Date(dateTo);
dateToEnd.setHours(23, 59, 59, 999);
const rows = await prisma.$queryRaw<
{ period: Date; outcome: string; count: number }[]
>`
SELECT DATE_TRUNC(${granularity}, "visitedAt") as period,
outcome::text as outcome,
COUNT(*)::int as count
FROM canvass_visits
WHERE "visitedAt" >= ${dateFrom} AND "visitedAt" <= ${dateToEnd}
GROUP BY period, outcome
ORDER BY period ASC
`;
// Pivot rows into series: [{ date, NOT_HOME: n, SPOKE_WITH: n, ... }]
const pivotMap = new Map<string, Record<string, number>>();
const totals: Record<string, number> = {};
for (const row of rows) {
const dateStr = row.period.toISOString().split('T')[0];
if (!pivotMap.has(dateStr)) {
pivotMap.set(dateStr, {});
}
pivotMap.get(dateStr)![row.outcome] = row.count;
totals[row.outcome] = (totals[row.outcome] || 0) + row.count;
}
const series = Array.from(pivotMap.entries()).map(([date, outcomes]) => ({
date,
...outcomes,
}));
return {
granularity,
dateFrom: dateFrom.toISOString().split('T')[0],
dateTo: dateTo.toISOString().split('T')[0],
series,
totals,
};
},
// ─── Helpers ───────────────────────────────────────────────────────
async recalculateCutCompletion(cutId: string) {

View File

@@ -33,16 +33,23 @@ export async function authenticate(
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
const queryToken = (request.query as Record<string, string>)?.token;
if (!authHeader?.startsWith('Bearer ')) {
// Support both Authorization header and ?token= query param (for <img>/<video> src)
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (queryToken) {
token = queryToken;
}
if (!token) {
return reply.status(401).send({
error: 'Authentication required',
code: 'AUTH_REQUIRED'
});
}
const token = authHeader.substring(7);
// Verify JWT with V2 access secret
let payload: TokenPayload;
try {
@@ -133,12 +140,18 @@ export async function optionalAuth(
_reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
const queryToken = (request.query as Record<string, string>)?.token;
if (!authHeader?.startsWith('Bearer ')) {
return;
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (queryToken) {
token = queryToken;
}
const token = authHeader.substring(7);
if (!token) {
return;
}
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;

View File

@@ -0,0 +1,364 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
/**
* Photo album CRUD routes (prefix: /api/albums)
*/
interface CreateAlbumBody {
title: string;
description?: string;
photoIds?: number[];
}
interface UpdateAlbumBody {
title?: string;
description?: string;
category?: string;
accessLevel?: string;
}
interface AddPhotosBody {
photoIds: number[];
}
interface ReorderBody {
photoIds: number[];
}
interface SetCoverBody {
photoId: number;
}
export async function photoAlbumsRoutes(fastify: FastifyInstance) {
// GET /api/albums - List albums
fastify.get<{ Querystring: { limit?: string; offset?: string; search?: string } }>(
'/',
{ preHandler: requireAdminRole },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '48'), 200);
const offset = parseInt(request.query.offset || '0');
const search = request.query.search;
const where: any = {};
if (search) {
where.title = { contains: search, mode: 'insensitive' };
}
const [albums, total] = await Promise.all([
prisma.photoAlbum.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
coverPhoto: {
select: { id: true, thumbnailPath: true },
},
creator: {
select: { id: true, name: true, email: true },
},
_count: { select: { photos: true } },
},
}),
prisma.photoAlbum.count({ where }),
]);
return {
albums: albums.map(a => ({
...a,
photoCount: a._count.photos,
coverThumbnailUrl: a.coverPhoto?.thumbnailPath
? `/media/photos/${a.coverPhoto.id}/thumbnail`
: null,
})),
total,
limit,
offset,
};
}
);
// POST /api/albums - Create album
fastify.post<{ Body: CreateAlbumBody }>(
'/',
{ preHandler: requireAdminRole },
async (request, reply) => {
const { title, description, photoIds } = request.body;
if (!title?.trim()) {
return reply.code(400).send({ message: 'Title is required' });
}
const album = await prisma.photoAlbum.create({
data: {
title: title.trim(),
description: description?.trim() || null,
creatorId: request.user?.id || null,
photoCount: photoIds?.length || 0,
},
});
// Move photos into album if provided
if (photoIds?.length) {
for (let i = 0; i < photoIds.length; i++) {
await prisma.photo.update({
where: { id: photoIds[i] },
data: { albumId: album.id, albumPosition: i },
});
}
// Set first photo as cover
await prisma.photoAlbum.update({
where: { id: album.id },
data: { coverPhotoId: photoIds[0] },
});
}
return reply.code(201).send({ album });
}
);
// GET /api/albums/:id - Album detail with photos
fastify.get<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const album = await prisma.photoAlbum.findUnique({
where: { id },
include: {
coverPhoto: { select: { id: true, thumbnailPath: true } },
creator: { select: { id: true, name: true, email: true } },
photos: {
orderBy: { albumPosition: 'asc' },
select: {
id: true,
title: true,
originalFilename: true,
thumbnailPath: true,
width: true,
height: true,
orientation: true,
format: true,
fileSize: true,
albumPosition: true,
isPublished: true,
createdAt: true,
},
},
},
});
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
return {
...album,
photos: album.photos.map(p => ({
...p,
fileSize: p.fileSize?.toString() ?? null,
thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null,
})),
};
}
);
// PATCH /api/albums/:id - Update album metadata
fastify.patch<{ Params: { id: string }; Body: UpdateAlbumBody }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const { title, description, category, accessLevel } = request.body;
const album = await prisma.photoAlbum.findUnique({ where: { id } });
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
const updated = await prisma.photoAlbum.update({
where: { id },
data: {
...(title !== undefined && { title: title.trim() }),
...(description !== undefined && { description: description?.trim() || null }),
...(category !== undefined && { category }),
...(accessLevel !== undefined && { accessLevel }),
},
});
return updated;
}
);
// DELETE /api/albums/:id - Delete album (photos become orphaned, NOT deleted)
fastify.delete<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const album = await prisma.photoAlbum.findUnique({ where: { id } });
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
// Remove album reference from photos (orphan them)
await prisma.photo.updateMany({
where: { albumId: id },
data: { albumId: null, albumPosition: null },
});
await prisma.photoAlbum.delete({ where: { id } });
return { message: 'Album deleted, photos preserved' };
}
);
// POST /api/albums/:id/photos - Add photos to album
fastify.post<{ Params: { id: string }; Body: AddPhotosBody }>(
'/:id/photos',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const { photoIds } = request.body;
const album = await prisma.photoAlbum.findUnique({ where: { id: albumId } });
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
// Find current max position
const maxPos = await prisma.photo.aggregate({
where: { albumId },
_max: { albumPosition: true },
});
let nextPos = (maxPos._max.albumPosition ?? -1) + 1;
for (const photoId of photoIds) {
await prisma.photo.update({
where: { id: photoId },
data: { albumId, albumPosition: nextPos++ },
});
}
// Update photo count
const count = await prisma.photo.count({ where: { albumId } });
await prisma.photoAlbum.update({
where: { id: albumId },
data: { photoCount: count },
});
// Set cover if album has none
if (!album.coverPhotoId && photoIds.length > 0) {
await prisma.photoAlbum.update({
where: { id: albumId },
data: { coverPhotoId: photoIds[0] },
});
}
return { message: `Added ${photoIds.length} photos to album`, photoCount: count };
}
);
// DELETE /api/albums/:id/photos/:photoId - Remove photo from album
fastify.delete<{ Params: { id: string; photoId: string } }>(
'/:id/photos/:photoId',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const photoId = parseInt(request.params.photoId as string);
await prisma.photo.update({
where: { id: photoId },
data: { albumId: null, albumPosition: null },
});
// Clear cover if it was the removed photo
const album = await prisma.photoAlbum.findUnique({ where: { id: albumId } });
if (album?.coverPhotoId === photoId) {
// Set next photo as cover or null
const nextPhoto = await prisma.photo.findFirst({
where: { albumId },
orderBy: { albumPosition: 'asc' },
});
await prisma.photoAlbum.update({
where: { id: albumId },
data: { coverPhotoId: nextPhoto?.id ?? null },
});
}
// Update count
const count = await prisma.photo.count({ where: { albumId } });
await prisma.photoAlbum.update({
where: { id: albumId },
data: { photoCount: count },
});
return { message: 'Photo removed from album', photoCount: count };
}
);
// PUT /api/albums/:id/reorder - Reorder photos in album
fastify.put<{ Params: { id: string }; Body: ReorderBody }>(
'/:id/reorder',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const { photoIds } = request.body;
// Update positions
for (let i = 0; i < photoIds.length; i++) {
await prisma.photo.update({
where: { id: photoIds[i] },
data: { albumPosition: i },
});
}
return { message: 'Photos reordered' };
}
);
// PUT /api/albums/:id/cover - Set cover photo
fastify.put<{ Params: { id: string }; Body: SetCoverBody }>(
'/:id/cover',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const { photoId } = request.body;
await prisma.photoAlbum.update({
where: { id: albumId },
data: { coverPhotoId: photoId },
});
return { message: 'Cover photo set' };
}
);
// POST /api/albums/:id/publish - Publish album + all its photos
fastify.post<{ Params: { id: string } }>(
'/:id/publish',
{ preHandler: requireAdminRole },
async (request, reply) => {
const albumId = parseInt(request.params.id as string);
const now = new Date();
await Promise.all([
prisma.photoAlbum.update({
where: { id: albumId },
data: { isPublished: true, publishedAt: now },
}),
prisma.photo.updateMany({
where: { albumId },
data: { isPublished: true, publishedAt: now },
}),
]);
return { message: 'Album and photos published' };
}
);
}

View File

@@ -0,0 +1,253 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { createHash } from 'crypto';
import { logger } from '../../../utils/logger';
/**
* Photo engagement routes — upvotes, comments, reactions, views (prefix: /api)
*/
interface UpvoteParams {
id: string;
}
interface CommentBody {
content: string;
sessionId: string;
}
interface ReactionBody {
sessionId: string;
reactionType: string;
}
interface ViewBody {
photoId: number;
sessionId?: string;
}
const VALID_REACTIONS = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'];
export async function photoEngagementRoutes(fastify: FastifyInstance) {
// POST /api/photos/:id/upvote - Toggle upvote on
fastify.post<{ Params: UpvoteParams; Body: { sessionId: string } }>(
'/photos/:id/upvote',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const { sessionId } = request.body;
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId, userId: request.user?.id || null },
update: { lastSeenAt: new Date() },
});
// Check existing
const existing = await prisma.photoUpvote.findFirst({
where: { photoId, sessionId },
});
if (existing) {
return reply.code(409).send({ message: 'Already upvoted' });
}
await prisma.photoUpvote.create({
data: { photoId, sessionId },
});
// Increment counter
await prisma.photo.update({
where: { id: photoId },
data: { upvoteCount: { increment: 1 } },
});
return { message: 'Upvoted', upvoted: true };
}
);
// DELETE /api/photos/:id/upvote - Remove upvote
fastify.delete<{ Params: UpvoteParams; Body: { sessionId: string } }>(
'/photos/:id/upvote',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const sessionId = (request.body as any)?.sessionId || (request.query as any)?.sessionId;
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
const existing = await prisma.photoUpvote.findFirst({
where: { photoId, sessionId },
});
if (!existing) {
return reply.code(404).send({ message: 'No upvote found' });
}
await prisma.photoUpvote.delete({ where: { id: existing.id } });
await prisma.photo.update({
where: { id: photoId },
data: { upvoteCount: { decrement: 1 } },
});
return { message: 'Upvote removed', upvoted: false };
}
);
// GET /api/photos/:id/comments - Get comments
fastify.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
'/photos/:id/comments',
{ preHandler: optionalAuth },
async (request) => {
const photoId = parseInt(request.params.id as string);
const limit = Math.min(parseInt(request.query.limit || '50'), 200);
const offset = parseInt(request.query.offset || '0');
const [comments, total] = await Promise.all([
prisma.photoComment.findMany({
where: { photoId, isHidden: false, safetyStatus: 'approved' },
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
select: {
id: true,
content: true,
createdAt: true,
user: {
select: { id: true, name: true },
},
},
}),
prisma.photoComment.count({
where: { photoId, isHidden: false, safetyStatus: 'approved' },
}),
]);
return { comments, total, limit, offset };
}
);
// POST /api/photos/:id/comments - Add comment
fastify.post<{ Params: { id: string }; Body: CommentBody }>(
'/photos/:id/comments',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const { content, sessionId } = request.body;
if (!content?.trim()) {
return reply.code(400).send({ message: 'Content is required' });
}
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId, userId: request.user?.id || null },
update: { lastSeenAt: new Date() },
});
const comment = await prisma.photoComment.create({
data: {
photoId,
sessionId,
userId: request.user?.id || null,
content: content.trim().slice(0, 2000), // Max 2000 chars
},
});
// Increment counter
await prisma.photo.update({
where: { id: photoId },
data: { commentCount: { increment: 1 } },
});
return reply.code(201).send({ comment });
}
);
// POST /api/photos/:id/reactions - Add reaction
fastify.post<{ Params: { id: string }; Body: ReactionBody }>(
'/photos/:id/reactions',
{ preHandler: optionalAuth },
async (request, reply) => {
const photoId = parseInt(request.params.id as string);
const { sessionId, reactionType } = request.body;
if (!sessionId) {
return reply.code(400).send({ message: 'sessionId is required' });
}
if (!VALID_REACTIONS.includes(reactionType)) {
return reply.code(400).send({ message: `Invalid reaction. Must be: ${VALID_REACTIONS.join(', ')}` });
}
// Ensure session exists
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId, userId: request.user?.id || null },
update: { lastSeenAt: new Date() },
});
// Upsert reaction (one per session per type)
await prisma.photoReaction.upsert({
where: {
photoId_sessionId_reactionType: {
photoId,
sessionId,
reactionType,
},
},
create: { photoId, sessionId, reactionType },
update: {},
});
return { message: 'Reaction added' };
}
);
// POST /api/track/photo-view - Record photo view
fastify.post<{ Body: ViewBody }>(
'/track/photo-view',
{ preHandler: optionalAuth },
async (request, reply) => {
const { photoId, sessionId } = request.body;
if (!photoId) {
return reply.code(400).send({ message: 'photoId is required' });
}
// Hash IP for privacy
const ipRaw = request.ip || request.headers['x-forwarded-for'] || '';
const ipStr = Array.isArray(ipRaw) ? ipRaw[0] : ipRaw;
const ipHash = createHash('sha256').update(ipStr).digest('hex').slice(0, 16);
await prisma.photoView.create({
data: {
photoId,
sessionId: sessionId || null,
userId: request.user?.id || null,
ipAddressHash: ipHash,
},
});
// Increment counter
await prisma.photo.update({
where: { id: photoId },
data: { viewCount: { increment: 1 } },
});
return { message: 'View recorded' };
}
);
}

View File

@@ -0,0 +1,269 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import { unlink, mkdir } from 'fs/promises';
import { join, extname } from 'path';
import { randomUUID } from 'crypto';
import { prisma } from '../../../config/database';
import {
ALLOWED_IMAGE_EXTENSIONS,
validateImage,
extractPhotoMetadata,
generateVariants,
getPhotoInboxDir,
} from '../services/photo-processing.service';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
/**
* Photo upload routes (prefix: /api/photos)
*/
export async function photoUploadRoutes(fastify: FastifyInstance) {
// POST /api/photos/upload - Single photo upload
fastify.post(
'/upload',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
let tempFilePath: string | null = null;
try {
const data = await request.file();
if (!data) {
return reply.code(400).send({ message: 'No file uploaded' });
}
// Validate file extension
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
return reply.code(400).send({
message: `Invalid file type. Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`,
});
}
// Generate unique filename and ensure inbox dir exists
const filename = `${randomUUID()}${ext}`;
const inboxDir = getPhotoInboxDir();
await mkdir(inboxDir, { recursive: true });
const filePath = join(inboxDir, filename);
tempFilePath = filePath;
// Stream file to disk
logger.info(`Uploading photo to ${filePath}`);
await pipeline(data.file, createWriteStream(filePath));
// Extract metadata fields from form data
const metadataFields = data.fields as Record<string, { value: string }>;
const title = metadataFields.title?.value;
const producer = metadataFields.producer?.value;
const creator = metadataFields.creator?.value;
const albumIdStr = metadataFields.albumId?.value;
const albumId = albumIdStr ? parseInt(albumIdStr) : null;
// Validate image
logger.info(`Validating image: ${filePath}`);
await validateImage(filePath);
// Extract metadata
logger.info(`Extracting photo metadata: ${filePath}`);
const metadata = await extractPhotoMetadata(filePath);
// Determine album position if adding to album
let albumPosition: number | null = null;
if (albumId) {
const maxPos = await prisma.photo.aggregate({
where: { albumId },
_max: { albumPosition: true },
});
albumPosition = (maxPos._max.albumPosition ?? -1) + 1;
}
// Insert into database
const photo = await prisma.photo.create({
data: {
path: filePath,
filename,
originalFilename: data.filename,
title: title || data.filename,
producer: producer || null,
creator: creator || null,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
fileSize: BigInt(metadata.fileSize),
format: metadata.format,
colorSpace: metadata.colorSpace,
hasAlpha: metadata.hasAlpha,
dpi: metadata.dpi,
cameraMake: metadata.cameraMake,
cameraModel: metadata.cameraModel,
focalLength: metadata.focalLength,
aperture: metadata.aperture,
shutterSpeed: metadata.shutterSpeed,
iso: metadata.iso,
takenAt: metadata.takenAt,
gpsLatitude: metadata.gpsLatitude,
gpsLongitude: metadata.gpsLongitude,
albumId,
albumPosition,
uploaderId: request.user?.id || null,
},
});
logger.info(`Photo uploaded: ${photo.id}`);
// Generate image variants (thumbnail, medium, large, webp)
try {
const variants = await generateVariants(filePath, photo.id);
await prisma.photo.update({
where: { id: photo.id },
data: {
thumbnailPath: variants.thumbnailPath,
mediumPath: variants.mediumPath,
largePath: variants.largePath,
webpPath: variants.webpPath,
},
});
logger.info(`Generated variants for photo ${photo.id}`);
} catch (variantError) {
logger.error(`Failed to generate variants for photo ${photo.id}:`, variantError);
}
// Update album photo count if applicable
if (albumId) {
const count = await prisma.photo.count({ where: { albumId } });
await prisma.photoAlbum.update({
where: { id: albumId },
data: { photoCount: count },
});
}
return reply.code(201).send({
message: 'Photo uploaded successfully',
photo: { ...photo, fileSize: photo.fileSize?.toString() ?? null },
});
} catch (error) {
if (tempFilePath) {
try { await unlink(tempFilePath); } catch { /* ignore */ }
}
logger.error('Photo upload failed:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Upload failed',
});
}
}
);
// POST /api/photos/upload/batch - Batch photo upload
fastify.post(
'/upload/batch',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
try {
const files = request.files();
const results: Array<{ filename: string; success: boolean; error?: string; photo?: any }> = [];
for await (const file of files) {
let tempFilePath: string | null = null;
try {
const ext = extname(file.filename).toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
results.push({
filename: file.filename,
success: false,
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`,
});
continue;
}
const filename = `${randomUUID()}${ext}`;
const inboxDir = getPhotoInboxDir();
await mkdir(inboxDir, { recursive: true });
const filePath = join(inboxDir, filename);
tempFilePath = filePath;
await pipeline(file.file, createWriteStream(filePath));
await validateImage(filePath);
const metadata = await extractPhotoMetadata(filePath);
const photo = await prisma.photo.create({
data: {
path: filePath,
filename,
originalFilename: file.filename,
title: file.filename,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
fileSize: BigInt(metadata.fileSize),
format: metadata.format,
colorSpace: metadata.colorSpace,
hasAlpha: metadata.hasAlpha,
dpi: metadata.dpi,
cameraMake: metadata.cameraMake,
cameraModel: metadata.cameraModel,
focalLength: metadata.focalLength,
aperture: metadata.aperture,
shutterSpeed: metadata.shutterSpeed,
iso: metadata.iso,
takenAt: metadata.takenAt,
gpsLatitude: metadata.gpsLatitude,
gpsLongitude: metadata.gpsLongitude,
uploaderId: request.user?.id || null,
},
});
// Generate variants
try {
const variants = await generateVariants(filePath, photo.id);
await prisma.photo.update({
where: { id: photo.id },
data: {
thumbnailPath: variants.thumbnailPath,
mediumPath: variants.mediumPath,
largePath: variants.largePath,
webpPath: variants.webpPath,
},
});
} catch (variantError) {
logger.error(`Failed to generate variants for photo ${photo.id}:`, variantError);
}
results.push({
filename: file.filename,
success: true,
photo: { ...photo, fileSize: photo.fileSize?.toString() ?? null },
});
logger.info(`Batch upload: ${file.filename} -> photo ${photo.id}`);
} catch (error) {
if (tempFilePath) {
try { await unlink(tempFilePath); } catch { /* ignore */ }
}
logger.error(`Batch upload failed for ${file.filename}:`, error);
results.push({
filename: file.filename,
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
});
}
}
const successCount = results.filter(r => r.success).length;
const failCount = results.length - successCount;
return reply.code(207).send({
message: `Batch upload: ${successCount} succeeded, ${failCount} failed`,
results,
});
} catch (error) {
logger.error('Batch photo upload failed:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Batch upload failed',
});
}
}
);
}

View File

@@ -0,0 +1,473 @@
import { FastifyInstance } from 'fastify';
import { createReadStream } from 'fs';
import { access } from 'fs/promises';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { logger } from '../../../utils/logger';
/**
* Public photo/album/gallery endpoints (prefix: /api)
*/
interface PublicPhotosQuery {
limit?: string;
offset?: string;
sort?: 'recent' | 'popular' | 'oldest';
category?: string;
}
interface UnifiedGalleryQuery {
limit?: string;
offset?: string;
sort?: 'recent' | 'popular';
category?: string;
mediaType?: 'all' | 'video' | 'photo';
}
interface ImageQuery {
size?: 'thumb' | 'medium' | 'large';
}
export async function photosPublicRoutes(fastify: FastifyInstance) {
// GET /api/public/photos - Published photos (paginated)
fastify.get<{ Querystring: PublicPhotosQuery }>(
'/public/photos',
{ preHandler: optionalAuth },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
const offset = parseInt(request.query.offset || '0');
const sort = request.query.sort || 'recent';
const category = request.query.category;
const where: any = {
isPublished: true,
isLocked: false,
};
if (category) where.category = category;
let orderBy: any = { publishedAt: 'desc' };
if (sort === 'oldest') orderBy = { publishedAt: 'asc' };
if (sort === 'popular') orderBy = { viewCount: 'desc' };
const [photos, total] = await Promise.all([
prisma.photo.findMany({
where,
select: {
id: true,
title: true,
width: true,
height: true,
orientation: true,
format: true,
producer: true,
category: true,
publishedAt: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
albumId: true,
createdAt: true,
},
orderBy,
take: limit,
skip: offset,
}),
prisma.photo.count({ where }),
]);
return {
photos: photos.map(p => ({
...p,
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
imageUrl: `/media/public/photos/${p.id}/image`,
})),
pagination: { total, limit, offset, hasMore: offset + limit < total },
};
}
);
// GET /api/public/photos/:id - Single published photo
fastify.get<{ Params: { id: string } }>(
'/public/photos/:id',
{ preHandler: optionalAuth },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findFirst({
where: { id, isPublished: true, isLocked: false },
select: {
id: true,
title: true,
description: true,
width: true,
height: true,
orientation: true,
format: true,
producer: true,
creator: true,
category: true,
cameraMake: true,
cameraModel: true,
focalLength: true,
aperture: true,
shutterSpeed: true,
iso: true,
takenAt: true,
publishedAt: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
albumId: true,
createdAt: true,
},
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
return {
...photo,
thumbnailUrl: `/media/public/photos/${photo.id}/thumbnail`,
imageUrl: `/media/public/photos/${photo.id}/image`,
};
}
);
// GET /api/public/photos/:id/image - Serve optimized image
fastify.get<{ Params: { id: string }; Querystring: ImageQuery }>(
'/public/photos/:id/image',
async (request, reply) => {
const id = parseInt(request.params.id as string);
const size = request.query.size || 'large';
const photo = await prisma.photo.findFirst({
where: { id, isPublished: true, isLocked: false },
select: {
thumbnailPath: true,
mediumPath: true,
largePath: true,
webpPath: true,
format: true,
},
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
// Check if client accepts WebP
const acceptsWebP = request.headers.accept?.includes('image/webp');
// Pick the right variant
let filePath: string | null = null;
let contentType = 'image/jpeg';
if (acceptsWebP && photo.webpPath) {
filePath = photo.webpPath;
contentType = 'image/webp';
} else {
switch (size) {
case 'thumb':
filePath = photo.thumbnailPath;
break;
case 'medium':
filePath = photo.mediumPath;
break;
case 'large':
default:
filePath = photo.largePath;
break;
}
}
if (!filePath || filePath.includes('..')) {
return reply.code(404).send({ message: 'Image variant not found' });
}
try {
await access(filePath);
} catch {
return reply.code(404).send({ message: 'Image file not found' });
}
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(filePath));
}
);
// GET /api/public/photos/:id/thumbnail - Serve thumbnail
fastify.get<{ Params: { id: string } }>(
'/public/photos/:id/thumbnail',
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findFirst({
where: { id, isPublished: true, isLocked: false },
select: { thumbnailPath: true },
});
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
try {
await access(photo.thumbnailPath);
} catch {
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(photo.thumbnailPath));
}
);
// GET /api/public/albums - Published albums
fastify.get<{ Querystring: PublicPhotosQuery }>(
'/public/albums',
{ preHandler: optionalAuth },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
const offset = parseInt(request.query.offset || '0');
const category = request.query.category;
const where: any = { isPublished: true, isLocked: false };
if (category) where.category = category;
const [albums, total] = await Promise.all([
prisma.photoAlbum.findMany({
where,
select: {
id: true,
title: true,
description: true,
category: true,
photoCount: true,
viewCount: true,
upvoteCount: true,
publishedAt: true,
coverPhoto: {
select: { id: true, thumbnailPath: true, width: true, height: true },
},
},
orderBy: { publishedAt: 'desc' },
take: limit,
skip: offset,
}),
prisma.photoAlbum.count({ where }),
]);
return {
albums: albums.map(a => ({
...a,
coverThumbnailUrl: a.coverPhoto
? `/media/public/photos/${a.coverPhoto.id}/thumbnail`
: null,
})),
pagination: { total, limit, offset, hasMore: offset + limit < total },
};
}
);
// GET /api/public/albums/:id - Published album with photos
fastify.get<{ Params: { id: string } }>(
'/public/albums/:id',
{ preHandler: optionalAuth },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const album = await prisma.photoAlbum.findFirst({
where: { id, isPublished: true, isLocked: false },
include: {
coverPhoto: { select: { id: true, thumbnailPath: true } },
photos: {
where: { isPublished: true, isLocked: false },
orderBy: { albumPosition: 'asc' },
select: {
id: true,
title: true,
width: true,
height: true,
orientation: true,
format: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
},
},
},
});
if (!album) {
return reply.code(404).send({ message: 'Album not found' });
}
return {
...album,
photos: album.photos.map(p => ({
...p,
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
imageUrl: `/media/public/photos/${p.id}/image`,
})),
};
}
);
// GET /api/public/gallery - Unified feed (videos + photos + albums)
fastify.get<{ Querystring: UnifiedGalleryQuery }>(
'/public/gallery',
{ preHandler: optionalAuth },
async (request) => {
const limit = Math.min(parseInt(request.query.limit || '24'), 100);
const offset = parseInt(request.query.offset || '0');
const sort = request.query.sort || 'recent';
const category = request.query.category;
const mediaType = request.query.mediaType || 'all';
const orderByDate = sort === 'popular' ? undefined : 'desc';
const items: Array<{ type: 'video' | 'photo' | 'album'; data: any; publishedAt: Date }> = [];
// Fetch videos if needed
if (mediaType === 'all' || mediaType === 'video') {
const videoWhere: any = { isPublished: true, isLocked: false };
if (category) videoWhere.category = category;
const videos = await prisma.video.findMany({
where: videoWhere,
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
width: true,
height: true,
orientation: true,
quality: true,
producer: true,
thumbnailPath: true,
publishedAt: true,
category: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
isShort: true,
createdAt: true,
},
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
take: limit + offset, // Over-fetch for merge
});
for (const v of videos) {
items.push({
type: 'video',
data: {
...v,
duration: v.durationSeconds,
thumbnailUrl: v.thumbnailPath ? `/media/videos/${v.id}/thumbnail` : null,
videoUrl: `/media/videos/${v.id}/stream`,
},
publishedAt: v.publishedAt || v.createdAt,
});
}
}
// Fetch photos (non-album) if needed
if (mediaType === 'all' || mediaType === 'photo') {
const photoWhere: any = { isPublished: true, isLocked: false, albumId: null };
if (category) photoWhere.category = category;
const photos = await prisma.photo.findMany({
where: photoWhere,
select: {
id: true,
title: true,
width: true,
height: true,
orientation: true,
format: true,
producer: true,
category: true,
publishedAt: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
createdAt: true,
},
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
take: limit + offset,
});
for (const p of photos) {
items.push({
type: 'photo',
data: {
...p,
thumbnailUrl: `/media/public/photos/${p.id}/thumbnail`,
imageUrl: `/media/public/photos/${p.id}/image`,
},
publishedAt: p.publishedAt || p.createdAt,
});
}
// Fetch albums
const albumWhere: any = { isPublished: true, isLocked: false };
if (category) albumWhere.category = category;
const albums = await prisma.photoAlbum.findMany({
where: albumWhere,
select: {
id: true,
title: true,
description: true,
category: true,
photoCount: true,
viewCount: true,
upvoteCount: true,
publishedAt: true,
createdAt: true,
coverPhoto: {
select: { id: true, thumbnailPath: true, width: true, height: true },
},
},
orderBy: sort === 'popular' ? { viewCount: 'desc' } : { publishedAt: 'desc' },
take: limit + offset,
});
for (const a of albums) {
items.push({
type: 'album',
data: {
...a,
coverThumbnailUrl: a.coverPhoto
? `/media/public/photos/${a.coverPhoto.id}/thumbnail`
: null,
},
publishedAt: a.publishedAt || a.createdAt,
});
}
}
// Sort merged results
if (sort === 'popular') {
items.sort((a, b) => (b.data.viewCount || 0) - (a.data.viewCount || 0));
} else {
items.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
}
// Apply offset and limit
const paged = items.slice(offset, offset + limit);
return {
items: paged.map(({ type, data }) => ({ type, data })),
pagination: {
total: items.length,
limit,
offset,
hasMore: offset + limit < items.length,
},
};
}
);
}

View File

@@ -0,0 +1,388 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { unlink } from 'fs/promises';
/**
* Admin photo CRUD routes (prefix: /api/photos)
*/
interface PhotosQuery {
limit?: string;
offset?: string;
search?: string;
format?: string;
orientation?: 'H' | 'V' | 'S';
producer?: string;
albumId?: string;
isPublished?: string;
}
interface PhotoUpdateBody {
title?: string;
description?: string;
producer?: string;
creator?: string;
tags?: string[];
category?: string;
accessLevel?: string;
}
interface BulkIdsBody {
ids: number[];
}
export async function photosRoutes(fastify: FastifyInstance) {
// GET /api/photos - List photos (admin, paginated)
fastify.get<{ Querystring: PhotosQuery }>(
'/',
{ preHandler: requireAdminRole },
async (request, reply) => {
const limit = Math.min(parseInt(request.query.limit || '48'), 200);
const offset = parseInt(request.query.offset || '0');
const { search, format, orientation, producer, albumId, isPublished } = request.query;
const where: any = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ originalFilename: { contains: search, mode: 'insensitive' } },
{ producer: { contains: search, mode: 'insensitive' } },
];
}
if (format) where.format = format;
if (orientation) where.orientation = orientation;
if (producer) where.producer = producer;
if (albumId) where.albumId = parseInt(albumId);
if (isPublished !== undefined) where.isPublished = isPublished === 'true';
const [photos, total] = await Promise.all([
prisma.photo.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
album: { select: { id: true, title: true } },
},
}),
prisma.photo.count({ where }),
]);
return {
photos: photos.map(p => ({
...p,
fileSize: p.fileSize?.toString() ?? null,
thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null,
})),
total,
limit,
offset,
};
}
);
// GET /api/photos/producers - Distinct producers list
fastify.get(
'/producers',
{ preHandler: requireAdminRole },
async () => {
const results = await prisma.photo.findMany({
where: { producer: { not: null } },
select: { producer: true },
distinct: ['producer'],
});
return results.map(r => r.producer).filter(Boolean);
}
);
// GET /api/photos/formats - Distinct formats list
fastify.get(
'/formats',
{ preHandler: requireAdminRole },
async () => {
const results = await prisma.photo.findMany({
where: { format: { not: null } },
select: { format: true },
distinct: ['format'],
});
return results.map(r => r.format).filter(Boolean);
}
);
// GET /api/photos/:id - Single photo detail
fastify.get<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({
where: { id },
include: {
album: { select: { id: true, title: true } },
uploader: { select: { id: true, name: true, email: true } },
},
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
return {
...photo,
fileSize: photo.fileSize?.toString() ?? null,
thumbnailUrl: photo.thumbnailPath ? `/media/photos/${photo.id}/thumbnail` : null,
};
}
);
// GET /api/photos/:id/thumbnail - Serve thumbnail image (admin)
fastify.get<{ Params: { id: string } }>(
'/:id/thumbnail',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({
where: { id },
select: { thumbnailPath: true },
});
if (!photo?.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
if (photo.thumbnailPath.includes('..')) {
return reply.code(403).send({ message: 'Access denied' });
}
const { createReadStream } = await import('fs');
const { access } = await import('fs/promises');
try {
await access(photo.thumbnailPath);
} catch {
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'public, max-age=86400');
return reply.send(createReadStream(photo.thumbnailPath));
}
);
// GET /api/photos/:id/image - Serve full image (admin, size: thumb/medium/large)
fastify.get<{ Params: { id: string }; Querystring: { size?: string } }>(
'/:id/image',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const size = request.query.size || 'large';
const photo = await prisma.photo.findUnique({
where: { id },
select: { thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true, path: true, format: true },
});
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
// Pick variant based on size param
let filePath: string | null = null;
let contentType = 'image/jpeg';
switch (size) {
case 'thumb':
filePath = photo.thumbnailPath;
break;
case 'medium':
filePath = photo.mediumPath;
break;
case 'large':
default:
filePath = photo.largePath || photo.mediumPath;
break;
}
if (!filePath || filePath.includes('..')) {
return reply.code(404).send({ message: 'Image variant not found' });
}
const { createReadStream } = await import('fs');
const { access } = await import('fs/promises');
try {
await access(filePath);
} catch {
return reply.code(404).send({ message: 'Image file not found' });
}
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=86400');
return reply.send(createReadStream(filePath));
}
);
// PATCH /api/photos/:id - Update photo metadata
fastify.patch<{ Params: { id: string }; Body: PhotoUpdateBody }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const { title, description, producer, creator, tags, category, accessLevel } = request.body;
const photo = await prisma.photo.findUnique({ where: { id } });
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
const updated = await prisma.photo.update({
where: { id },
data: {
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(producer !== undefined && { producer }),
...(creator !== undefined && { creator }),
...(tags !== undefined && { tags: tags as any }),
...(category !== undefined && { category }),
...(accessLevel !== undefined && { accessLevel }),
},
});
return { ...updated, fileSize: updated.fileSize?.toString() ?? null };
}
);
// DELETE /api/photos/:id - Delete photo + variant files
fastify.delete<{ Params: { id: string } }>(
'/:id',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({ where: { id } });
if (!photo) {
return reply.code(404).send({ message: 'Photo not found' });
}
// Delete variant files
const filesToDelete = [
photo.path,
photo.thumbnailPath,
photo.mediumPath,
photo.largePath,
photo.webpPath,
].filter(Boolean) as string[];
for (const filePath of filesToDelete) {
try {
await unlink(filePath);
} catch {
// File may already be gone
}
}
// If this was a cover photo for an album, clear the cover
if (photo.albumId) {
await prisma.photoAlbum.updateMany({
where: { coverPhotoId: id },
data: { coverPhotoId: null },
});
}
await prisma.photo.delete({ where: { id } });
return { message: 'Photo deleted' };
}
);
// POST /api/photos/:id/publish
fastify.post<{ Params: { id: string } }>(
'/:id/publish',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.update({
where: { id },
data: { isPublished: true, publishedAt: new Date() },
});
return { ...photo, fileSize: photo.fileSize?.toString() ?? null };
}
);
// POST /api/photos/:id/unpublish
fastify.post<{ Params: { id: string } }>(
'/:id/unpublish',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.update({
where: { id },
data: { isPublished: false },
});
return { ...photo, fileSize: photo.fileSize?.toString() ?? null };
}
);
// POST /api/photos/bulk-publish
fastify.post<{ Body: BulkIdsBody }>(
'/bulk-publish',
{ preHandler: requireAdminRole },
async (request) => {
const { ids } = request.body;
const result = await prisma.photo.updateMany({
where: { id: { in: ids } },
data: { isPublished: true, publishedAt: new Date() },
});
return { updated: result.count };
}
);
// POST /api/photos/bulk-unpublish
fastify.post<{ Body: BulkIdsBody }>(
'/bulk-unpublish',
{ preHandler: requireAdminRole },
async (request) => {
const { ids } = request.body;
const result = await prisma.photo.updateMany({
where: { id: { in: ids } },
data: { isPublished: false },
});
return { updated: result.count };
}
);
// POST /api/photos/bulk-delete
fastify.post<{ Body: BulkIdsBody }>(
'/bulk-delete',
{ preHandler: requireAdminRole },
async (request) => {
const { ids } = request.body;
// Get file paths before deleting
const photos = await prisma.photo.findMany({
where: { id: { in: ids } },
select: { path: true, thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true },
});
// Delete files
for (const photo of photos) {
const paths = [photo.path, photo.thumbnailPath, photo.mediumPath, photo.largePath, photo.webpPath].filter(Boolean) as string[];
for (const p of paths) {
try { await unlink(p); } catch { /* ignore */ }
}
}
// Clear album covers referencing these photos
await prisma.photoAlbum.updateMany({
where: { coverPhotoId: { in: ids } },
data: { coverPhotoId: null },
});
const result = await prisma.photo.deleteMany({ where: { id: { in: ids } } });
return { deleted: result.count };
}
);
}

View File

@@ -0,0 +1,282 @@
import sharp from 'sharp';
import { stat, mkdir } from 'fs/promises';
import { join, extname } from 'path';
import { logger } from '../../../utils/logger';
// Supported image extensions
export const ALLOWED_IMAGE_EXTENSIONS = [
'.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tiff', '.tif', '.heic', '.heif',
];
// Variant output directories (relative to /media/local/photos/)
const PHOTOS_BASE = '/media/local/photos';
const DIRS = {
inbox: join(PHOTOS_BASE, 'inbox'),
thumbnails: join(PHOTOS_BASE, 'thumbnails'),
medium: join(PHOTOS_BASE, 'medium'),
large: join(PHOTOS_BASE, 'large'),
webp: join(PHOTOS_BASE, 'webp'),
};
export interface PhotoMetadata {
width: number;
height: number;
orientation: 'H' | 'V' | 'S';
fileSize: number;
format: string;
colorSpace: string | null;
hasAlpha: boolean;
dpi: number | null;
// EXIF
cameraMake: string | null;
cameraModel: string | null;
focalLength: string | null;
aperture: string | null;
shutterSpeed: string | null;
iso: number | null;
takenAt: Date | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
}
export interface PhotoVariants {
thumbnailPath: string;
mediumPath: string;
largePath: string;
webpPath: string;
}
/**
* Validate that a file is a real image (not just a renamed binary).
* Returns metadata if valid, throws if not.
*/
export async function validateImage(filePath: string): Promise<sharp.Metadata> {
try {
const metadata = await sharp(filePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Image has no dimensions');
}
return metadata;
} catch (error) {
throw new Error(
`Invalid image file: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
}
/**
* Extract metadata from an image file including EXIF data.
*/
export async function extractPhotoMetadata(filePath: string): Promise<PhotoMetadata> {
const metadata = await sharp(filePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Image has no dimensions');
}
const fileStat = await stat(filePath);
// Determine orientation
let orientation: 'H' | 'V' | 'S' = 'H';
if (metadata.width === metadata.height) {
orientation = 'S';
} else if (metadata.height > metadata.width) {
orientation = 'V';
}
// Parse EXIF data
const exif = metadata.exif ? parseExif(metadata) : null;
return {
width: metadata.width,
height: metadata.height,
orientation,
fileSize: fileStat.size,
format: metadata.format || extname(filePath).slice(1).toLowerCase(),
colorSpace: metadata.space || null,
hasAlpha: metadata.hasAlpha || false,
dpi: metadata.density || null,
cameraMake: exif?.make || null,
cameraModel: exif?.model || null,
focalLength: exif?.focalLength || null,
aperture: exif?.aperture || null,
shutterSpeed: exif?.shutterSpeed || null,
iso: exif?.iso || null,
takenAt: exif?.takenAt || null,
gpsLatitude: exif?.gpsLatitude || null,
gpsLongitude: exif?.gpsLongitude || null,
};
}
interface ParsedExif {
make: string | null;
model: string | null;
focalLength: string | null;
aperture: string | null;
shutterSpeed: string | null;
iso: number | null;
takenAt: Date | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
}
/**
* Parse EXIF data from sharp metadata.
* sharp exposes raw EXIF buffer; we parse key IFD tags manually.
*/
function parseExif(metadata: sharp.Metadata): ParsedExif {
const result: ParsedExif = {
make: null,
model: null,
focalLength: null,
aperture: null,
shutterSpeed: null,
iso: null,
takenAt: null,
gpsLatitude: null,
gpsLongitude: null,
};
// sharp provides some EXIF-derived fields directly on metadata
// For deeper EXIF, we'd need exif-reader, but sharp gives us basics
// We'll use the raw exif buffer if available
try {
if (metadata.exif) {
// Use dynamic import for exif-reader if available, otherwise skip
// For now, rely on sharp's built-in metadata fields
// sharp exposes: orientation, density (DPI)
// Additional EXIF requires the exif-reader package
// Try to parse raw EXIF with built-in support
const exifBuffer = metadata.exif;
if (exifBuffer && exifBuffer.length > 0) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const exifReader = require('exif-reader');
const parsed = exifReader(exifBuffer);
if (parsed.Image) {
result.make = parsed.Image.Make || null;
result.model = parsed.Image.Model || null;
}
if (parsed.Photo || parsed.Exif) {
const photo = parsed.Photo || parsed.Exif || {};
if (photo.FocalLength) result.focalLength = `${photo.FocalLength}mm`;
if (photo.FNumber) result.aperture = `f/${photo.FNumber}`;
if (photo.ExposureTime) {
result.shutterSpeed = photo.ExposureTime < 1
? `1/${Math.round(1 / photo.ExposureTime)}s`
: `${photo.ExposureTime}s`;
}
if (photo.ISOSpeedRatings) {
result.iso = Array.isArray(photo.ISOSpeedRatings)
? photo.ISOSpeedRatings[0]
: photo.ISOSpeedRatings;
}
if (photo.DateTimeOriginal) {
result.takenAt = new Date(photo.DateTimeOriginal);
}
}
if (parsed.GPSInfo || parsed.GPS) {
const gps = parsed.GPSInfo || parsed.GPS || {};
if (gps.GPSLatitude && gps.GPSLatitudeRef) {
result.gpsLatitude = dmsToDecimal(gps.GPSLatitude, gps.GPSLatitudeRef);
}
if (gps.GPSLongitude && gps.GPSLongitudeRef) {
result.gpsLongitude = dmsToDecimal(gps.GPSLongitude, gps.GPSLongitudeRef);
}
}
} catch {
// exif-reader not available or parse failed — that's fine
logger.debug('EXIF parsing skipped (exif-reader not available or parse error)');
}
}
}
} catch (error) {
logger.debug('EXIF extraction failed', { error });
}
return result;
}
/**
* Convert DMS (degrees/minutes/seconds) array to decimal degrees.
*/
function dmsToDecimal(dms: number[], ref: string): number {
if (!dms || dms.length < 3) return 0;
let decimal = dms[0] + dms[1] / 60 + dms[2] / 3600;
if (ref === 'S' || ref === 'W') decimal = -decimal;
return Math.round(decimal * 1_000_000) / 1_000_000;
}
/**
* Ensure all photo variant directories exist.
*/
export async function ensurePhotoDirs(): Promise<void> {
for (const dir of Object.values(DIRS)) {
await mkdir(dir, { recursive: true });
}
}
/**
* Generate all image variants (thumbnail, medium, large, webp).
* Auto-orients using EXIF data, strips GPS from output variants.
*/
export async function generateVariants(
sourcePath: string,
photoId: number
): Promise<PhotoVariants> {
await ensurePhotoDirs();
const thumbPath = join(DIRS.thumbnails, `${photoId}_thumb.jpg`);
const mediumPath = join(DIRS.medium, `${photoId}_medium.jpg`);
const largePath = join(DIRS.large, `${photoId}_large.jpg`);
const webpPath = join(DIRS.webp, `${photoId}.webp`);
// Base pipeline: auto-orient (apply EXIF rotation) and strip metadata (privacy)
const basePipeline = () => sharp(sourcePath).rotate().withMetadata({ orientation: undefined });
// Generate all variants in parallel
await Promise.all([
// Thumbnail: 320px longest edge, JPEG q80
basePipeline()
.resize(320, 320, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumbPath),
// Medium: 800px longest edge, JPEG q85
basePipeline()
.resize(800, 800, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(mediumPath),
// Large: 1600px longest edge, JPEG q90
basePipeline()
.resize(1600, 1600, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 90 })
.toFile(largePath),
// WebP: 1200px longest edge, WebP q82
basePipeline()
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(webpPath),
]);
logger.info(`Generated 4 variants for photo ${photoId}`);
return {
thumbnailPath: thumbPath,
mediumPath,
largePath,
webpPath,
};
}
/**
* Get the inbox directory path for photo uploads.
*/
export function getPhotoInboxDir(): string {
return DIRS.inbox;
}

View File

@@ -0,0 +1,48 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { quickJoinRateLimit } from '../../middleware/rate-limit';
import { volunteerInviteService } from './volunteer-invite.service';
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
const router = Router();
// POST /api/volunteer-invite/generate — Admin-only: create a signed invite token
router.post(
'/generate',
authenticate,
requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'),
validate(generateInviteSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { cutId, shiftId } = req.body;
const token = volunteerInviteService.generateInviteToken(req.user!.id, cutId, shiftId);
res.json({ token });
} catch (err) {
next(err);
}
},
);
// POST /api/volunteer-invite/redeem — Public: redeem an invite token
router.post(
'/redeem',
quickJoinRateLimit,
validate(redeemInviteSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await volunteerInviteService.redeemInvite(req.body);
res.json({
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
cutId: result.cutId,
shiftId: result.shiftId,
});
} catch (err) {
next(err);
}
},
);
export { router as volunteerInviteRouter };

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
export const generateInviteSchema = z.object({
cutId: z.string().optional(),
shiftId: z.string().optional(),
});
export const redeemInviteSchema = z.object({
token: z.string().min(1),
email: z.string().email(),
name: z.string().max(200).optional(),
phone: z.string().max(50).optional(),
});
export type GenerateInviteInput = z.infer<typeof generateInviteSchema>;
export type RedeemInviteInput = z.infer<typeof redeemInviteSchema>;

View File

@@ -0,0 +1,106 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { UserCreatedVia, UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { authService } from '../auth/auth.service';
import { AppError } from '../../middleware/error-handler';
import type { RedeemInviteInput } from './volunteer-invite.schemas';
interface InviteTokenPayload {
type: 'volunteer_invite';
adminUserId: string;
cutId?: string;
shiftId?: string;
}
export const volunteerInviteService = {
/**
* Generate a signed invite token (JWT, 30 min expiry).
* Contains the inviting admin's ID and optional cut/shift context.
*/
generateInviteToken(adminUserId: string, cutId?: string, shiftId?: string): string {
const payload: InviteTokenPayload = {
type: 'volunteer_invite',
adminUserId,
...(cutId && { cutId }),
...(shiftId && { shiftId }),
};
return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: '30m' });
},
/**
* Redeem an invite token: verify it, create (or reactivate) a TEMP user,
* and return a JWT token pair for immediate login.
*/
async redeemInvite(input: RedeemInviteInput) {
// 1. Verify and decode the invite token
let payload: InviteTokenPayload;
try {
const decoded = jwt.verify(input.token, env.JWT_ACCESS_SECRET);
payload = decoded as InviteTokenPayload;
} catch {
throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN');
}
// 2. Validate token type to prevent JWT confusion attacks
if (payload.type !== 'volunteer_invite') {
throw new AppError(400, 'Invalid invite token', 'INVALID_TOKEN_TYPE');
}
const email = input.email.toLowerCase().trim();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // +24h
// 3. Check for existing user
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
// Active non-TEMP user — just generate new token pair (re-login)
if (existingUser.status === UserStatus.ACTIVE && existingUser.role !== UserRole.TEMP) {
const tokens = await authService.generateTokenPair(existingUser);
return { tokens, user: existingUser, cutId: payload.cutId, shiftId: payload.shiftId };
}
// Expired or inactive TEMP user — reactivate with extended expiry
if (existingUser.role === UserRole.TEMP) {
const reactivated = await prisma.user.update({
where: { id: existingUser.id },
data: {
status: UserStatus.ACTIVE,
expiresAt,
name: input.name || existingUser.name,
phone: input.phone || existingUser.phone,
},
});
const tokens = await authService.generateTokenPair(reactivated);
return { tokens, user: reactivated, cutId: payload.cutId, shiftId: payload.shiftId };
}
// Suspended/inactive non-TEMP user — block
throw new AppError(403, 'Account is not active. Please contact an administrator.', 'ACCOUNT_INACTIVE');
}
// 4. Create new TEMP user with random password (never shown to user)
const randomPassword = crypto.randomBytes(16).toString('hex');
const hashedPassword = await bcrypt.hash(randomPassword, 10);
const newUser = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: input.name || null,
phone: input.phone || null,
role: UserRole.TEMP,
roles: JSON.stringify([UserRole.TEMP]),
status: UserStatus.ACTIVE,
createdVia: UserCreatedVia.QUICK_JOIN_INVITE,
expiresAt,
},
});
const tokens = await authService.generateTokenPair(newUser);
return { tokens, user: newUser, cutId: payload.cutId, shiftId: payload.shiftId };
},
};

View File

@@ -64,6 +64,7 @@ import { galleryAdsPublicRouter } from './modules/gallery-ads/gallery-ads-public
import { galleryAdsAdminRouter } from './modules/gallery-ads/gallery-ads-admin.routes';
import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.routes';
import { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes';
import { volunteerInviteRouter } from './modules/volunteer-invite/volunteer-invite.routes';
import { docsAnalyticsService } from './modules/docs-analytics/docs-analytics.service';
const app = express();
@@ -197,6 +198,7 @@ app.use('/api/gallery-ads', galleryAdsPublicRouter); // Public gallery
app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN)
app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth)
app.use('/api/docs-analytics', docsAnalyticsAdminRouter); // Admin docs analytics (ADMIN roles)
app.use('/api/volunteer-invite', volunteerInviteRouter); // Quick join invite (admin generate + public redeem)
// --- Error Handler (must be last) ---
app.use(errorHandler);