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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ interface TokenPayload {
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
interface TokenPair {
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
364
api/src/modules/media/routes/photo-albums.routes.ts
Normal file
364
api/src/modules/media/routes/photo-albums.routes.ts
Normal 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' };
|
||||
}
|
||||
);
|
||||
}
|
||||
253
api/src/modules/media/routes/photo-engagement.routes.ts
Normal file
253
api/src/modules/media/routes/photo-engagement.routes.ts
Normal 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' };
|
||||
}
|
||||
);
|
||||
}
|
||||
269
api/src/modules/media/routes/photo-upload.routes.ts
Normal file
269
api/src/modules/media/routes/photo-upload.routes.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
473
api/src/modules/media/routes/photos-public.routes.ts
Normal file
473
api/src/modules/media/routes/photos-public.routes.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
388
api/src/modules/media/routes/photos.routes.ts
Normal file
388
api/src/modules/media/routes/photos.routes.ts
Normal 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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
282
api/src/modules/media/services/photo-processing.service.ts
Normal file
282
api/src/modules/media/services/photo-processing.service.ts
Normal 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;
|
||||
}
|
||||
48
api/src/modules/volunteer-invite/volunteer-invite.routes.ts
Normal file
48
api/src/modules/volunteer-invite/volunteer-invite.routes.ts
Normal 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 };
|
||||
16
api/src/modules/volunteer-invite/volunteer-invite.schemas.ts
Normal file
16
api/src/modules/volunteer-invite/volunteer-invite.schemas.ts
Normal 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>;
|
||||
106
api/src/modules/volunteer-invite/volunteer-invite.service.ts
Normal file
106
api/src/modules/volunteer-invite/volunteer-invite.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user