feat: Add photo management API endpoints for fetching, favoriting, reporting, and tagging photos
All checks were successful
CI / skip-ci-check (pull_request) Successful in 1m49s
CI / lint-and-type-check (pull_request) Successful in 2m28s
CI / python-lint (pull_request) Successful in 2m14s
CI / test-backend (pull_request) Successful in 4m7s
CI / build (pull_request) Successful in 4m58s
CI / secret-scanning (pull_request) Successful in 2m0s
CI / dependency-scan (pull_request) Successful in 1m54s
CI / sast-scan (pull_request) Successful in 3m6s
CI / workflow-summary (pull_request) Successful in 1m47s

- Implemented GET endpoint to retrieve photo details by ID, including associated faces and tags.
- Added GET endpoint for fetching adjacent photos based on date taken.
- Created POST endpoint to toggle favorites for photos, including user authentication checks.
- Developed POST and GET endpoints for reporting photos, with caching for report statuses.
- Introduced POST endpoint for bulk toggling of favorites.
- Implemented batch processing for checking report statuses.
- Added endpoint for managing tag linkages, including validation and error handling.
- Created upload endpoint for handling photo uploads with size and type validation.

These changes enhance the photo management capabilities of the application, allowing users to interact with photos more effectively.
This commit is contained in:
Tanya 2026-01-21 14:33:59 -05:00
parent f879e660a4
commit dd8dd0808e
11 changed files with 2101 additions and 1 deletions

1
.gitignore vendored
View File

@ -57,7 +57,6 @@ Thumbs.db
.history/
photos/
# Photo files and large directories
*.jpg

View File

@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
return NextResponse.json(
{ error: 'Invalid photo ID' },
{ status: 400 }
);
}
// Get the current photo to find its date_taken for ordering
const currentPhoto = await prisma.photo.findUnique({
where: { id: photoId },
select: { date_taken: true },
});
if (!currentPhoto) {
return NextResponse.json(
{ error: 'Photo not found' },
{ status: 404 }
);
}
// Get previous photo (earlier date_taken, or same date_taken with higher ID)
const previousPhoto = await prisma.photo.findFirst({
where: {
processed: true,
OR: [
{
date_taken: {
lt: currentPhoto.date_taken || new Date(),
},
},
{
date_taken: currentPhoto.date_taken,
id: {
gt: photoId,
},
},
],
},
orderBy: [
{ date_taken: 'desc' },
{ id: 'asc' },
],
select: { id: true },
});
// Get next photo (later date_taken, or same date_taken with lower ID)
const nextPhoto = await prisma.photo.findFirst({
where: {
processed: true,
OR: [
{
date_taken: {
gt: currentPhoto.date_taken || new Date(),
},
},
{
date_taken: currentPhoto.date_taken,
id: {
lt: photoId,
},
},
],
},
orderBy: [
{ date_taken: 'asc' },
{ id: 'desc' },
],
select: { id: true },
});
return NextResponse.json({
previous: previousPhoto?.id || null,
next: nextPhoto?.id || null,
});
} catch (error) {
console.error('Error fetching adjacent photos:', error);
return NextResponse.json(
{ error: 'Failed to fetch adjacent photos' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
// POST - Toggle favorite (add if not exists, remove if exists)
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to favorite photos.' },
{ status: 401 }
);
}
const { id } = await params;
const photoId = parseInt(id, 10);
const userId = parseInt(session.user.id, 10);
if (isNaN(photoId)) {
return NextResponse.json(
{ error: 'Invalid photo ID' },
{ status: 400 }
);
}
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Verify photo exists in main database
const photo = await prisma.photo.findUnique({
where: { id: photoId },
select: { id: true, filename: true },
});
if (!photo) {
return NextResponse.json(
{ error: 'Photo not found' },
{ status: 404 }
);
}
// Verify user exists in auth database
const user = await prismaAuth.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
console.error(`[FAVORITE] User ${userId} not found in auth database`);
return NextResponse.json(
{ error: 'User account not found. Please sign in again.' },
{ status: 404 }
);
}
// Check if favorite already exists
const existing = await prismaAuth.photoFavorite.findUnique({
where: {
uq_photo_user_favorite: {
photoId,
userId,
},
},
});
if (existing) {
// Remove favorite
await prismaAuth.photoFavorite.delete({
where: { id: existing.id },
});
return NextResponse.json({
favorited: false,
message: 'Photo removed from favorites'
});
} else {
// Add favorite
await prismaAuth.photoFavorite.create({
data: {
photoId,
userId,
favoritedAt: new Date(),
},
});
return NextResponse.json({
favorited: true,
message: 'Photo added to favorites'
});
}
} catch (error: any) {
console.error('Error toggling favorite:', error);
// Handle case where table doesn't exist yet (P2021 = table does not exist)
if (error.code === 'P2021') {
return NextResponse.json(
{ error: 'Favorites feature is not available yet. Please run the migration: migrations/add-photo-favorites-table.sql' },
{ status: 503 }
);
}
if (error.code === 'P2002') {
return NextResponse.json(
{ error: 'Favorite already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to toggle favorite', details: error.message },
{ status: 500 }
);
}
}
// GET - Check if photo is favorited
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ favorited: false });
}
const { id } = await params;
const photoId = parseInt(id, 10);
const userId = parseInt(session.user.id, 10);
if (isNaN(photoId) || isNaN(userId)) {
return NextResponse.json({ favorited: false });
}
const favorite = await prismaAuth.photoFavorite.findUnique({
where: {
uq_photo_user_favorite: {
photoId,
userId,
},
},
});
return NextResponse.json({ favorited: !!favorite });
} catch (error: any) {
// Handle case where table doesn't exist yet (P2021 = table does not exist)
if (error.code === 'P2021') {
// Table doesn't exist yet, return false (not favorited)
return NextResponse.json({ favorited: false });
}
console.error('Error checking favorite:', error);
return NextResponse.json({ favorited: false });
}
}

View File

@ -0,0 +1,391 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { readFile } from 'fs/promises';
import { createReadStream } from 'fs';
import { existsSync, statSync } from 'fs';
import path from 'path';
import { getVideoThumbnail } from '@/lib/video-thumbnail';
// Conditionally import sharp - handle case where libvips is not installed
let sharp: any = null;
try {
sharp = require('sharp');
} catch (error) {
console.warn('Sharp library not available. Watermarking and image processing will be disabled.');
console.warn('To enable image processing, install libvips: sudo apt-get install libvips-dev');
}
const WATERMARK_BUCKET_SIZE = 200;
const watermarkCache = new Map<string, Buffer>();
async function getWatermarkOverlay(baseWidth: number, baseHeight: number) {
if (!sharp) {
throw new Error('Sharp library not available');
}
const bucketWidth = Math.ceil(baseWidth / WATERMARK_BUCKET_SIZE) * WATERMARK_BUCKET_SIZE;
const bucketHeight = Math.ceil(baseHeight / WATERMARK_BUCKET_SIZE) * WATERMARK_BUCKET_SIZE;
const cacheKey = `${bucketWidth}x${bucketHeight}`;
let cached: Buffer | undefined = watermarkCache.get(cacheKey);
if (!cached) {
const diagonal = Math.sqrt(bucketWidth * bucketWidth + bucketHeight * bucketHeight);
const watermarkWidth = Math.round(diagonal * 1.05);
const watermarkHeight = Math.round((watermarkWidth * 120) / 360);
const watermarkSvg = `
<svg width="${watermarkWidth}" height="${watermarkHeight}" viewBox="0 0 360 120" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="wmGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#1a3c7c" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#0a1e3f" stop-opacity="0.45"/>
</linearGradient>
</defs>
<rect width="360" height="120" fill="url(#wmGradient)" rx="20" ry="20" />
<g fill="rgba(255,255,255,0.65)">
<text x="30" y="82" font-family="'Inter','Segoe UI',sans-serif" font-size="72" font-weight="700">J</text>
<text x="144" y="82" font-family="'Inter','Segoe UI',sans-serif" font-size="72" font-weight="700">M</text>
<g transform="translate(80,20) scale(0.7)">
<polygon points="60,0 80,35 120,35 90,57 100,95 60,75 20,95 30,57 0,35 40,35"
fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="12" stroke-linejoin="round" />
</g>
<text x="214" y="50" font-family="'Inter','Segoe UI',sans-serif" font-size="24" font-weight="600">Jewish</text>
<text x="214" y="78" font-family="'Inter','Segoe UI',sans-serif" font-size="24" font-weight="600">and Modern</text>
</g>
</svg>
`;
const rotationAngle = -Math.atan(bucketHeight / bucketWidth) * (180 / Math.PI);
const newCached = await sharp(Buffer.from(watermarkSvg))
.resize({
width: watermarkWidth,
height: watermarkHeight,
fit: 'inside',
withoutEnlargement: true,
})
.rotate(rotationAngle, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
.resize({
width: bucketWidth,
height: bucketHeight,
fit: 'cover',
position: 'centre',
})
.ensureAlpha()
.toBuffer();
watermarkCache.set(cacheKey, newCached);
cached = newCached;
}
// cached is guaranteed to be defined here (either from cache or just created)
return sharp(cached)
.resize({
width: baseWidth,
height: baseHeight,
fit: 'cover',
position: 'centre',
})
.ensureAlpha()
.toBuffer();
}
/**
* Get content type from file extension
*/
function getContentType(filePath: string, mediaType?: string): string {
const ext = path.extname(filePath).toLowerCase();
// If media_type is video, prioritize video MIME types
if (mediaType === 'video') {
if (ext === '.mp4') return 'video/mp4';
if (ext === '.webm') return 'video/webm';
if (ext === '.mov') return 'video/quicktime';
if (ext === '.avi') return 'video/x-msvideo';
if (ext === '.mkv') return 'video/x-matroska';
return 'video/mp4'; // default
}
// Image types
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.png') return 'image/png';
if (ext === '.gif') return 'image/gif';
if (ext === '.webp') return 'image/webp';
if (ext === '.heic') return 'image/heic';
if (ext === '.heif') return 'image/heif';
return 'image/jpeg'; // default
}
/**
* Handle HTTP range requests for video streaming
*/
function parseRange(range: string | null, fileSize: number): { start: number; end: number } | null {
if (!range) return null;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (isNaN(start) || isNaN(end) || start > end || start < 0) {
return null;
}
return { start, end };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const photoId = parseInt(id);
if (isNaN(photoId)) {
return new NextResponse('Invalid photo ID', { status: 400 });
}
const { searchParams } = new URL(request.url);
const thumbnail = searchParams.get('thumbnail') === 'true';
const watermark = searchParams.get('watermark') === 'true';
// Fetch photo from database
const photo = await prisma.photo.findUnique({
where: { id: photoId },
select: { path: true, filename: true, media_type: true },
});
if (!photo?.path) {
return new NextResponse('Photo not found', { status: 404 });
}
const filePath = photo.path;
const mediaType = photo.media_type || 'image';
// Check if file exists
if (!existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
return new NextResponse('File not found on server', { status: 404 });
}
// Handle video thumbnail request
if (thumbnail && mediaType === 'video') {
// Check if video path is a URL (can't generate thumbnail for remote videos)
const isVideoUrl = filePath.startsWith('http://') || filePath.startsWith('https://');
if (isVideoUrl) {
// For remote videos, we can't generate thumbnails locally
// Return a placeholder
console.warn(`Cannot generate thumbnail for remote video URL: ${filePath}`);
try {
if (!sharp) throw new Error('Sharp not available');
const placeholderSvg = `
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" fill="#e5e7eb"/>
<circle cx="200" cy="200" r="40" fill="#6b7280" opacity="0.8"/>
<polygon points="190,185 190,215 215,200" fill="white"/>
</svg>
`.trim();
const placeholderBuffer = await sharp(Buffer.from(placeholderSvg))
.png()
.toBuffer();
return new NextResponse(placeholderBuffer as unknown as BodyInit, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=60',
},
});
} catch (error) {
console.error('Error generating placeholder for remote video:', error);
const minimalPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
return new NextResponse(minimalPng as unknown as BodyInit, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=60',
},
});
}
}
try {
const thumbnailBuffer = await getVideoThumbnail(filePath);
if (thumbnailBuffer && thumbnailBuffer.length > 0) {
return new NextResponse(thumbnailBuffer as unknown as BodyInit, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}
console.error(`Thumbnail generation returned empty buffer for video: ${filePath}`);
} catch (error) {
console.error(`Error generating thumbnail for video ${filePath}:`, error);
}
// Fallback: return a placeholder image (gray box with play icon)
// Generate a PNG placeholder using sharp for better compatibility
try {
if (!sharp) throw new Error('Sharp not available');
const placeholderSvg = `
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" fill="#e5e7eb"/>
<circle cx="200" cy="200" r="40" fill="#6b7280" opacity="0.8"/>
<polygon points="190,185 190,215 215,200" fill="white"/>
</svg>
`.trim();
const placeholderBuffer = await sharp(Buffer.from(placeholderSvg))
.png()
.toBuffer();
return new NextResponse(placeholderBuffer as unknown as BodyInit, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=60', // Short cache for placeholder
},
});
} catch (error) {
console.error('Error generating placeholder:', error);
// Ultimate fallback: return a minimal 1x1 PNG
const minimalPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
return new NextResponse(minimalPng as unknown as BodyInit, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=60',
},
});
}
}
// Handle video streaming
if (mediaType === 'video') {
const fileStats = statSync(filePath);
const fileSize = fileStats.size;
const range = request.headers.get('range');
const contentType = getContentType(filePath, mediaType);
// Handle range requests for video seeking
if (range) {
const rangeData = parseRange(range, fileSize);
if (!rangeData) {
return new NextResponse('Range Not Satisfiable', {
status: 416,
headers: {
'Content-Range': `bytes */${fileSize}`,
},
});
}
const { start, end } = rangeData;
const chunkSize = end - start + 1;
const fileBuffer = await readFile(filePath);
const chunk = fileBuffer.slice(start, end + 1);
return new NextResponse(chunk, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
});
}
// Full video file (no range request)
const videoBuffer = await readFile(filePath);
return new NextResponse(videoBuffer, {
headers: {
'Content-Type': contentType,
'Content-Length': fileSize.toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600',
},
});
}
// Handle images (existing logic)
const imageBuffer = await readFile(filePath);
const contentType = getContentType(filePath, mediaType);
const createOriginalResponse = (cacheControl = 'public, max-age=31536000, immutable') =>
new NextResponse(imageBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': cacheControl,
},
});
if (!watermark) {
return createOriginalResponse();
}
// If sharp is not available, return original image without watermark
if (!sharp) {
console.warn('Sharp library not available, returning original image without watermark');
return createOriginalResponse('public, max-age=60');
}
try {
const metadata = await sharp(imageBuffer).metadata();
const baseWidth = metadata.width ?? 2000;
const baseHeight = metadata.height ?? 2000;
const overlayBuffer = await getWatermarkOverlay(baseWidth, baseHeight);
const ext = path.extname(filePath).toLowerCase();
const outputFormat =
ext === '.png' ? 'png' :
ext === '.webp' ? 'webp' :
'jpeg';
const pipeline = sharp(imageBuffer)
.composite([
{
input: overlayBuffer,
gravity: 'center',
blend: 'hard-light',
},
])
.toColorspace('srgb');
const result = await pipeline
.toFormat(
outputFormat,
outputFormat === 'png'
? { compressionLevel: 5 }
: outputFormat === 'webp'
? { quality: 90 }
: { quality: 90 }
)
.toBuffer();
const responseContentType =
outputFormat === 'png'
? 'image/png'
: outputFormat === 'webp'
? 'image/webp'
: 'image/jpeg';
return new NextResponse(result as unknown as BodyInit, {
headers: {
'Content-Type': responseContentType,
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error applying watermark:', error);
return createOriginalResponse('public, max-age=60');
}
} catch (error) {
console.error('Error serving media:', error);
return new NextResponse('Internal server error', { status: 500 });
}
}

View File

@ -0,0 +1,426 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
// In-memory cache for report statuses
// Key: `${userId}:${photoId}`, Value: { data, expiresAt }
interface CacheEntry {
data: {
reported: boolean;
status?: string;
reportedAt?: Date;
};
expiresAt: number;
}
const reportStatusCache = new Map<string, CacheEntry>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const MAX_COMMENT_LENGTH = 300;
// Helper function to get cache key
function getCacheKey(userId: number, photoId: number): string {
return `${userId}:${photoId}`;
}
// Helper function to get cached report status
function getCachedStatus(userId: number, photoId: number): CacheEntry['data'] | null {
const key = getCacheKey(userId, photoId);
const entry = reportStatusCache.get(key);
if (!entry) {
return null;
}
// Check if cache entry has expired
if (Date.now() > entry.expiresAt) {
reportStatusCache.delete(key);
return null;
}
return entry.data;
}
// Helper function to set cache
function setCachedStatus(
userId: number,
photoId: number,
data: CacheEntry['data']
): void {
const key = getCacheKey(userId, photoId);
reportStatusCache.set(key, {
data,
expiresAt: Date.now() + CACHE_TTL,
});
}
// Helper function to invalidate cache for a specific user/photo combination
function invalidateCache(userId: number, photoId: number): void {
const key = getCacheKey(userId, photoId);
reportStatusCache.delete(key);
}
// Helper function to fetch report status from database
async function fetchReportStatusFromDB(
userId: number,
photoId: number
): Promise<CacheEntry['data']> {
const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
where: {
uq_photo_user_report: {
photoId,
userId,
},
},
select: {
id: true,
status: true,
reportedAt: true,
},
});
const result: CacheEntry['data'] = existingReport
? {
reported: true,
status: existingReport.status,
reportedAt: existingReport.reportedAt,
}
: {
reported: false,
};
// Cache the result
setCachedStatus(userId, photoId, result);
return result;
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to report photos.' },
{ status: 401 }
);
}
const { id } = await params;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
return NextResponse.json(
{ error: 'Invalid photo ID' },
{ status: 400 }
);
}
// Verify photo exists
const photo = await prisma.photo.findUnique({
where: { id: photoId },
select: { id: true, filename: true },
});
if (!photo) {
return NextResponse.json(
{ error: 'Photo not found' },
{ status: 404 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Verify user exists in auth database before proceeding
const user = await prismaAuth.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
console.error(`[REPORT] User ${userId} not found in auth database`);
return NextResponse.json(
{ error: 'User account not found. Please sign in again.' },
{ status: 404 }
);
}
// Parse optional comment payload
let reportComment: string | null = null;
try {
const body = await request.json();
if (body && typeof body === 'object' && !Array.isArray(body)) {
if (body.comment !== undefined && typeof body.comment !== 'string' && body.comment !== null) {
return NextResponse.json(
{ error: 'Comment must be a string' },
{ status: 400 }
);
}
if (typeof body.comment === 'string') {
const trimmed = body.comment.trim();
if (trimmed.length > MAX_COMMENT_LENGTH) {
return NextResponse.json(
{ error: `Comment must be ${MAX_COMMENT_LENGTH} characters or less` },
{ status: 400 }
);
}
reportComment = trimmed.length > 0 ? trimmed : null;
}
}
} catch (error: any) {
// If the request body is empty or invalid JSON, treat as no comment
if (error.type !== 'invalid-json') {
console.warn('Invalid report payload received', error);
}
}
// Check if user has already reported this photo (get all info in one query)
const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
where: {
uq_photo_user_report: {
photoId,
userId,
},
},
});
let report;
if (existingReport) {
// If report exists and is still pending, return error
if (existingReport.status === 'pending') {
return NextResponse.json(
{ error: 'You have already reported this photo' },
{ status: 409 }
);
}
// If report was dismissed, cannot re-report
if (existingReport.status === 'dismissed') {
return NextResponse.json(
{ error: 'Cannot re-report a dismissed report' },
{ status: 403 }
);
}
// If report was reviewed, update it back to pending (re-report)
// Keep reviewNotes but clear reviewedAt and reviewedBy
if (existingReport.status === 'reviewed') {
report = await prismaAuth.inappropriatePhotoReport.update({
where: {
id: existingReport.id,
},
data: {
status: 'pending',
reviewedAt: null,
reviewedBy: null,
reportComment,
// Keep reviewNotes - don't clear them
},
});
// Invalidate cache after update
invalidateCache(userId, photoId);
}
} else {
// Create new report
report = await prismaAuth.inappropriatePhotoReport.create({
data: {
photoId,
userId,
status: 'pending',
reportComment,
},
});
// Invalidate cache after creation
invalidateCache(userId, photoId);
}
if (!report) {
return NextResponse.json(
{ error: 'Failed to create report' },
{ status: 500 }
);
}
return NextResponse.json(
{
message: 'Photo reported successfully',
report: {
id: report.id,
photoId: report.photoId,
status: report.status,
reportedAt: report.reportedAt,
reportComment: report.reportComment,
},
},
{ status: 201 }
);
} catch (error: any) {
console.error('Error reporting photo:', error);
// Handle unique constraint violation
if (error.code === 'P2002') {
return NextResponse.json(
{ error: 'You have already reported this photo' },
{ status: 409 }
);
}
// Handle foreign key constraint violation
if (error.code === 'P2003') {
console.error('[REPORT] Foreign key constraint violation:', error.meta);
return NextResponse.json(
{ error: 'User account not found. Please sign in again.' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to report photo', details: error.message },
{ status: 500 }
);
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const { id } = await params;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
return NextResponse.json(
{ error: 'Invalid photo ID' },
{ status: 400 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Check cache first
const cachedStatus = getCachedStatus(userId, photoId);
if (cachedStatus !== null) {
return NextResponse.json(cachedStatus);
}
// Cache miss - fetch from database (this will also cache the result)
const reportStatus = await fetchReportStatusFromDB(userId, photoId);
return NextResponse.json(reportStatus);
} catch (error: any) {
console.error('Error checking report status:', error);
return NextResponse.json(
{ error: 'Failed to check report status', details: error.message },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to undo reports.' },
{ status: 401 }
);
}
const { id } = await params;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
return NextResponse.json(
{ error: 'Invalid photo ID' },
{ status: 400 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Find the report
const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
where: {
uq_photo_user_report: {
photoId,
userId,
},
},
});
if (!existingReport) {
return NextResponse.json(
{ error: 'Report not found' },
{ status: 404 }
);
}
// Only allow undo if status is still pending
if (existingReport.status !== 'pending') {
return NextResponse.json(
{ error: 'Cannot undo report that has already been reviewed' },
{ status: 403 }
);
}
// Delete the report
await prismaAuth.inappropriatePhotoReport.delete({
where: {
id: existingReport.id,
},
});
// Invalidate cache after deletion
invalidateCache(userId, photoId);
return NextResponse.json(
{
message: 'Report undone successfully',
},
{ status: 200 }
);
} catch (error: any) {
console.error('Error undoing report:', error);
return NextResponse.json(
{ error: 'Failed to undo report', details: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
return NextResponse.json(
{ error: 'Invalid photo ID' },
{ status: 400 }
);
}
const photo = await prisma.photo.findUnique({
where: { id: photoId },
include: {
Face: {
include: {
Person: true,
},
},
PhotoTagLinkage: {
include: {
Tag: true,
},
},
},
});
if (!photo) {
return NextResponse.json(
{ error: 'Photo not found' },
{ status: 404 }
);
}
return NextResponse.json(photo);
} catch (error) {
console.error('Error fetching photo:', error);
return NextResponse.json(
{ error: 'Failed to fetch photo' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ results: {} });
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json({ results: {} });
}
const { photoIds } = await request.json();
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return NextResponse.json({ results: {} });
}
// Fetch all favorites for this user and these photos
const favorites = await prismaAuth.photoFavorite.findMany({
where: {
userId,
photoId: { in: photoIds },
},
select: {
photoId: true,
},
});
// Build result map
const favoriteSet = new Set(favorites.map(f => f.photoId));
const results: Record<string, boolean> = {};
photoIds.forEach((photoId: number) => {
results[photoId.toString()] = favoriteSet.has(photoId);
});
return NextResponse.json({ results });
} catch (error: any) {
// Handle case where table doesn't exist yet (P2021 = table does not exist)
if (error.code === 'P2021') {
console.warn('photo_favorites table does not exist yet. Run migration: migrations/add-photo-favorites-table.sql');
return NextResponse.json({ results: {} });
}
console.error('Error fetching batch favorites:', error);
return NextResponse.json({ results: {} });
}
}

View File

@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
// POST - Bulk toggle favorites (add if not exists, remove if exists)
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to favorite photos.' },
{ status: 401 }
);
}
const { photoIds, action } = await request.json();
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return NextResponse.json(
{ error: 'Photo IDs array is required' },
{ status: 400 }
);
}
// Validate action
if (action && action !== 'add' && action !== 'remove' && action !== 'toggle') {
return NextResponse.json(
{ error: 'Action must be "add", "remove", or "toggle"' },
{ status: 400 }
);
}
const validPhotoIds = photoIds
.map((id: any) => parseInt(id, 10))
.filter((id: number) => !isNaN(id));
if (validPhotoIds.length === 0) {
return NextResponse.json(
{ error: 'No valid photo IDs provided' },
{ status: 400 }
);
}
// Verify user exists in auth database
const user = await prismaAuth.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
return NextResponse.json(
{ error: 'User account not found. Please sign in again.' },
{ status: 404 }
);
}
// Verify photos exist in main database
const existingPhotos = await prisma.photo.findMany({
where: { id: { in: validPhotoIds } },
select: { id: true },
});
const existingPhotoIds = new Set(existingPhotos.map(p => p.id));
const validIds = validPhotoIds.filter((id: number) => existingPhotoIds.has(id));
if (validIds.length === 0) {
return NextResponse.json(
{ error: 'No valid photos found' },
{ status: 404 }
);
}
// Get current favorites for these photos
const currentFavorites = await prismaAuth.photoFavorite.findMany({
where: {
userId,
photoId: { in: validIds },
},
select: {
photoId: true,
id: true,
},
});
const favoritedPhotoIds = new Set(currentFavorites.map(f => f.photoId));
const favoriteIdsToDelete = new Set(currentFavorites.map(f => f.id));
let added = 0;
let removed = 0;
if (action === 'add') {
// Only add favorites that don't exist
const toAdd = validIds.filter(id => !favoritedPhotoIds.has(id));
if (toAdd.length > 0) {
await prismaAuth.photoFavorite.createMany({
data: toAdd.map(photoId => ({
photoId,
userId,
favoritedAt: new Date(),
})),
skipDuplicates: true,
});
added = toAdd.length;
}
} else if (action === 'remove') {
// Only remove favorites that exist
if (favoriteIdsToDelete.size > 0) {
await prismaAuth.photoFavorite.deleteMany({
where: {
id: { in: Array.from(favoriteIdsToDelete) },
},
});
removed = favoriteIdsToDelete.size;
}
} else {
// Toggle: add if not favorited, remove if favorited
const toAdd = validIds.filter(id => !favoritedPhotoIds.has(id));
const toRemove = Array.from(favoriteIdsToDelete);
if (toAdd.length > 0) {
await prismaAuth.photoFavorite.createMany({
data: toAdd.map(photoId => ({
photoId,
userId,
favoritedAt: new Date(),
})),
skipDuplicates: true,
});
added = toAdd.length;
}
if (toRemove.length > 0) {
await prismaAuth.photoFavorite.deleteMany({
where: {
id: { in: toRemove },
},
});
removed = toRemove.length;
}
}
return NextResponse.json({
success: true,
added,
removed,
total: validIds.length,
message: `Favorites updated: ${added} added, ${removed} removed`,
});
} catch (error: any) {
console.error('Error bulk toggling favorites:', error);
// Handle case where table doesn't exist yet (P2021 = table does not exist)
if (error.code === 'P2021') {
return NextResponse.json(
{ error: 'Favorites feature is not available yet. Please run the migration: migrations/add-photo-favorites-table.sql' },
{ status: 503 }
);
}
return NextResponse.json(
{ error: 'Failed to update favorites', details: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { prismaAuth } from '@/lib/db';
// In-memory cache for report statuses
// Key: `${userId}:${photoId}`, Value: { data, expiresAt }
interface CacheEntry {
data: {
reported: boolean;
status?: string;
reportedAt?: Date;
};
expiresAt: number;
}
const reportStatusCache = new Map<string, CacheEntry>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Helper function to get cache key
function getCacheKey(userId: number, photoId: number): string {
return `${userId}:${photoId}`;
}
// Helper function to get cached report status
function getCachedStatus(userId: number, photoId: number): CacheEntry['data'] | null {
const key = getCacheKey(userId, photoId);
const entry = reportStatusCache.get(key);
if (!entry) {
return null;
}
// Check if cache entry has expired
if (Date.now() > entry.expiresAt) {
reportStatusCache.delete(key);
return null;
}
return entry.data;
}
// Helper function to set cache
function setCachedStatus(
userId: number,
photoId: number,
data: CacheEntry['data']
): void {
const key = getCacheKey(userId, photoId);
reportStatusCache.set(key, {
data,
expiresAt: Date.now() + CACHE_TTL,
});
}
// Helper function to fetch report status from database
async function fetchReportStatusFromDB(
userId: number,
photoId: number
): Promise<CacheEntry['data']> {
const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
where: {
uq_photo_user_report: {
photoId,
userId,
},
},
select: {
id: true,
status: true,
reportedAt: true,
},
});
const result: CacheEntry['data'] = existingReport
? {
reported: true,
status: existingReport.status,
reportedAt: existingReport.reportedAt,
}
: {
reported: false,
};
// Cache the result
setCachedStatus(userId, photoId, result);
return result;
}
export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Parse request body
const body = await request.json();
const { photoIds } = body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return NextResponse.json(
{ error: 'photoIds must be a non-empty array' },
{ status: 400 }
);
}
// Validate photo IDs are numbers
const validPhotoIds = photoIds
.map((id) => parseInt(String(id), 10))
.filter((id) => !isNaN(id) && id > 0);
if (validPhotoIds.length === 0) {
return NextResponse.json(
{ error: 'No valid photo IDs provided' },
{ status: 400 }
);
}
// Limit batch size to prevent abuse
const MAX_BATCH_SIZE = 100;
const photoIdsToCheck = validPhotoIds.slice(0, MAX_BATCH_SIZE);
// Check cache first for all photos
const results: Record<number, CacheEntry['data']> = {};
const uncachedPhotoIds: number[] = [];
for (const photoId of photoIdsToCheck) {
const cached = getCachedStatus(userId, photoId);
if (cached !== null) {
results[photoId] = cached;
} else {
uncachedPhotoIds.push(photoId);
}
}
// Fetch uncached report statuses from database in a single query
if (uncachedPhotoIds.length > 0) {
const reports = await prismaAuth.inappropriatePhotoReport.findMany({
where: {
userId,
photoId: { in: uncachedPhotoIds },
},
select: {
photoId: true,
status: true,
reportedAt: true,
},
});
// Create a map of photoId -> report status
const reportMap = new Map<number, { status: string; reportedAt: Date }>();
for (const report of reports) {
reportMap.set(report.photoId, {
status: report.status,
reportedAt: report.reportedAt,
});
}
// Build results for uncached photos
for (const photoId of uncachedPhotoIds) {
const report = reportMap.get(photoId);
const result: CacheEntry['data'] = report
? {
reported: true,
status: report.status,
reportedAt: report.reportedAt,
}
: {
reported: false,
};
results[photoId] = result;
// Cache the result
setCachedStatus(userId, photoId, result);
}
}
return NextResponse.json({ results });
} catch (error: any) {
console.error('Error fetching batch report statuses:', error);
return NextResponse.json(
{ error: 'Failed to fetch report statuses', details: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,279 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
const MAX_TAG_NAME_LENGTH = 100;
const MAX_NOTES_LENGTH = 500;
const MAX_CUSTOM_TAGS = 10;
interface TagLinkagePayload {
photoIds?: number[];
tagIds?: number[];
newTagName?: string;
newTagNames?: string[];
notes?: string;
}
function parseIds(values: unknown, label: string) {
if (!Array.isArray(values)) {
throw new Error(`${label} must be an array`);
}
const parsed = Array.from(
new Set(
values.map((value) => {
const num = Number(value);
if (!Number.isInteger(num) || num <= 0) {
throw new Error(`${label} must contain positive integers`);
}
return num;
})
)
);
if (parsed.length === 0) {
throw new Error(`${label} cannot be empty`);
}
return parsed;
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to tag photos.' },
{ status: 401 }
);
}
const userId = parseInt(session.user.id, 10);
if (!Number.isInteger(userId)) {
return NextResponse.json(
{ error: 'Invalid user session' },
{ status: 401 }
);
}
let payload: TagLinkagePayload;
try {
payload = await request.json();
} catch (error: any) {
if (error?.type === 'invalid-json') {
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
}
throw error;
}
if (!payload || typeof payload !== 'object') {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!payload.photoIds) {
return NextResponse.json({ error: 'photoIds are required' }, { status: 400 });
}
const photoIds = parseIds(payload.photoIds, 'photoIds');
const tagIds =
Array.isArray(payload.tagIds) && payload.tagIds.length > 0
? parseIds(payload.tagIds, 'tagIds')
: [];
const incomingNewNames: string[] = [];
if (Array.isArray(payload.newTagNames)) {
incomingNewNames.push(...payload.newTagNames);
}
if (typeof payload.newTagName === 'string') {
incomingNewNames.push(payload.newTagName);
}
const newNameMap = new Map<string, string>();
for (const rawName of incomingNewNames) {
if (typeof rawName !== 'string') continue;
const trimmed = rawName.trim().replace(/\s+/g, ' ');
if (trimmed.length === 0) continue;
if (trimmed.length > MAX_TAG_NAME_LENGTH) {
return NextResponse.json(
{ error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or less` },
{ status: 400 }
);
}
const key = trimmed.toLowerCase();
if (!newNameMap.has(key)) {
newNameMap.set(key, trimmed);
}
}
const normalizedNewNames = Array.from(newNameMap.values());
if (normalizedNewNames.length > MAX_CUSTOM_TAGS) {
return NextResponse.json(
{ error: `You can submit up to ${MAX_CUSTOM_TAGS} new tag names at a time` },
{ status: 400 }
);
}
if (tagIds.length === 0 && normalizedNewNames.length === 0) {
return NextResponse.json(
{ error: 'Select at least one existing tag or provide a new tag name' },
{ status: 400 }
);
}
let notes: string | null = null;
if (typeof payload.notes === 'string') {
notes = payload.notes.trim();
if (notes.length > MAX_NOTES_LENGTH) {
return NextResponse.json(
{ error: `Notes must be ${MAX_NOTES_LENGTH} characters or less` },
{ status: 400 }
);
}
if (notes.length === 0) {
notes = null;
}
}
// Ensure all photos exist
const photos = await prisma.photo.findMany({
where: { id: { in: photoIds } },
select: { id: true },
});
if (photos.length !== photoIds.length) {
return NextResponse.json(
{ error: 'One or more photos were not found' },
{ status: 404 }
);
}
// Ensure all tag IDs exist
if (tagIds.length > 0) {
const tags = await prisma.tag.findMany({
where: { id: { in: tagIds } },
select: { id: true },
});
if (tags.length !== tagIds.length) {
return NextResponse.json(
{ error: 'One or more tags were not found' },
{ status: 404 }
);
}
}
// Skip combinations that are already applied
let appliedCombinationKeys = new Set<string>();
if (tagIds.length > 0) {
const applied = await prisma.photoTagLinkage.findMany({
where: {
photo_id: { in: photoIds },
tag_id: { in: tagIds },
},
select: {
photo_id: true,
tag_id: true,
},
});
appliedCombinationKeys = new Set(
applied.map((link) => `${link.photo_id}:${link.tag_id}`)
);
}
// Skip combinations that are already pending for the same user
const pendingConditions: any[] = [];
if (tagIds.length > 0) {
pendingConditions.push({
photo_id: { in: photoIds },
tag_id: { in: tagIds },
});
}
normalizedNewNames.forEach((name) => {
pendingConditions.push({
photo_id: { in: photoIds },
tag_name: name,
});
});
let pendingCombinationKeys = new Set<string>();
if (pendingConditions.length > 0) {
const existingPending = await prismaAuth.pendingLinkage.findMany({
where: {
userId,
status: 'pending',
OR: pendingConditions,
},
select: {
photoId: true,
tagId: true,
tagName: true,
},
});
pendingCombinationKeys = new Set(
existingPending.map((item) =>
item.tagId
? `${item.photoId}:${item.tagId}`
: `${item.photoId}:name:${item.tagName?.toLowerCase()}`
)
);
}
type PendingEntry = { photoId: number; tagId?: number; tagName?: string | null };
const entries: PendingEntry[] = [];
for (const photoId of photoIds) {
for (const tagId of tagIds) {
const key = `${photoId}:${tagId}`;
if (appliedCombinationKeys.has(key)) {
continue;
}
if (pendingCombinationKeys.has(key)) {
continue;
}
entries.push({ photoId, tagId });
}
for (const tagName of normalizedNewNames) {
const key = `${photoId}:name:${tagName.toLowerCase()}`;
if (!pendingCombinationKeys.has(key)) {
entries.push({ photoId, tagName });
}
}
}
if (entries.length === 0) {
return NextResponse.json(
{ error: 'Selected tag linkages are already applied or pending' },
{ status: 409 }
);
}
await prismaAuth.pendingLinkage.createMany({
data: entries.map((entry) => ({
photoId: entry.photoId,
tagId: entry.tagId ?? null,
tagName: entry.tagName ?? null,
userId,
status: 'pending',
notes,
})),
});
return NextResponse.json({
message: 'Tags were submitted and are pending approval',
pendingCount: entries.length,
});
} catch (error: any) {
console.error('Error submitting tag linkages:', error);
if (error.message?.includes('must')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json(
{ error: 'Failed to submit tag linkages', details: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,260 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { prismaAuth } from '@/lib/db';
import { writeFile, mkdir, access } from 'fs/promises';
import { existsSync } from 'fs';
import { constants } from 'fs';
import path from 'path';
// Maximum file size: 50MB for images, 500MB for videos
const MAX_IMAGE_SIZE = 50 * 1024 * 1024;
const MAX_VIDEO_SIZE = 500 * 1024 * 1024;
// Allowed image types
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
// Allowed video types
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
// Combined allowed types
const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES];
// Get upload directory from environment variable
// REQUIRED: Must be set to a network share path accessible by both this app and the approval system
// Examples:
// - Linux: /mnt/shared/pending-photos
// - Windows: \\server\share\pending-photos (mapped to drive or use UNC path)
// - SMB/CIFS: /mnt/smb/photos/pending
const getUploadDir = (): string => {
const uploadDir = process.env.UPLOAD_DIR || process.env.PENDING_PHOTOS_DIR;
if (!uploadDir) {
throw new Error(
'UPLOAD_DIR or PENDING_PHOTOS_DIR environment variable must be set. ' +
'This should point to a network share accessible by both the web server and approval system.'
);
}
// Use absolute path (network shares should already be absolute)
return path.resolve(uploadDir);
};
export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to upload files.' },
{ status: 401 }
);
}
const formData = await request.formData();
const photos = formData.getAll('photos') as File[];
if (photos.length === 0) {
return NextResponse.json(
{ error: 'No files provided' },
{ status: 400 }
);
}
// Validate files
const errors: string[] = [];
const validPhotos: Array<{ file: File; name: string; size: number; type: string }> = [];
for (const photo of photos) {
// Check file type
if (!ALLOWED_TYPES.includes(photo.type)) {
errors.push(`${photo.name}: Invalid file type. Only images (JPEG, PNG, GIF, WebP) and videos (MP4, MOV, AVI, WebM) are allowed.`);
continue;
}
// Check file size based on type
const isVideo = ALLOWED_VIDEO_TYPES.includes(photo.type);
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
const maxSizeMB = isVideo ? 500 : 50;
if (photo.size > maxSize) {
errors.push(`${photo.name}: File size exceeds ${maxSizeMB}MB limit.`);
continue;
}
if (photo.size === 0) {
errors.push(`${photo.name}: File is empty.`);
continue;
}
validPhotos.push({
file: photo,
name: photo.name,
size: photo.size,
type: photo.type,
});
}
if (validPhotos.length === 0) {
return NextResponse.json(
{
error: 'No valid files to upload',
details: errors.length > 0 ? errors : ['No files provided']
},
{ status: 400 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Get upload directory and ensure it exists
let uploadBaseDir: string;
try {
uploadBaseDir = getUploadDir();
} catch (error: any) {
console.error('[PHOTO UPLOAD] Configuration error:', error.message);
return NextResponse.json(
{
error: 'Upload directory not configured',
details: error.message + '. Please set UPLOAD_DIR or PENDING_PHOTOS_DIR environment variable to a network share path.'
},
{ status: 500 }
);
}
const userUploadDir = path.join(uploadBaseDir, userId.toString());
// Check if parent directory (mount point) exists and is accessible
const parentDir = path.dirname(uploadBaseDir);
if (!existsSync(parentDir)) {
console.error(`[PHOTO UPLOAD] Parent directory (mount point) does not exist: ${parentDir}`);
return NextResponse.json(
{
error: 'Upload directory mount point not found',
details: `The mount point ${parentDir} does not exist. Please ensure the network share is mounted. See docs/NETWORK_SHARE_SETUP.md for setup instructions.`
},
{ status: 500 }
);
}
// Check if parent directory is accessible (not just exists)
try {
await access(parentDir, constants.R_OK | constants.W_OK);
} catch (accessError: any) {
console.error(`[PHOTO UPLOAD] Cannot access parent directory: ${parentDir}`, accessError);
return NextResponse.json(
{
error: 'Upload directory not accessible',
details: `Cannot access mount point ${parentDir}. Please check permissions and ensure the network share is properly mounted. Error: ${accessError.message}`
},
{ status: 500 }
);
}
// Create directories if they don't exist
// Note: Network shares should already exist, but we create user subdirectories
try {
if (!existsSync(uploadBaseDir)) {
console.warn(`[PHOTO UPLOAD] Upload directory does not exist, attempting to create: ${uploadBaseDir}`);
await mkdir(uploadBaseDir, { recursive: true });
}
if (!existsSync(userUploadDir)) {
await mkdir(userUploadDir, { recursive: true });
}
} catch (error: any) {
// Check if error is permission-related
if (error.code === 'EACCES' || error.errno === -13) {
console.error(`[PHOTO UPLOAD] Permission denied creating directory: ${uploadBaseDir}`, error);
return NextResponse.json(
{
error: 'Permission denied',
details: `Permission denied when creating ${uploadBaseDir}. The mount point ${parentDir} exists but the application does not have write permissions. Please check:\n1. The network share is mounted with correct permissions\n2. The web server user has write access to the mount point\n3. See docs/NETWORK_SHARE_SETUP.md for troubleshooting`
},
{ status: 500 }
);
}
console.error(`[PHOTO UPLOAD] Failed to access/create upload directory: ${uploadBaseDir}`, error);
return NextResponse.json(
{
error: 'Failed to access upload directory',
details: `Cannot access network share at ${uploadBaseDir}. Please verify the path is correct and the server has read/write permissions. Error: ${error.message}`
},
{ status: 500 }
);
}
// Save files and create database records
const savedPhotos = [];
const saveErrors: string[] = [];
for (const photo of validPhotos) {
try {
// Generate unique filename: timestamp-originalname
const timestamp = Date.now();
const sanitizedOriginalName = photo.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const fileExtension = path.extname(photo.name);
const baseName = path.basename(sanitizedOriginalName, fileExtension);
const uniqueFilename = `${timestamp}-${baseName}${fileExtension}`;
const filePath = path.join(userUploadDir, uniqueFilename);
// Read file buffer
const arrayBuffer = await photo.file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Write file to disk
await writeFile(filePath, buffer);
// Create database record
const pendingPhoto = await prismaAuth.pendingPhoto.create({
data: {
userId,
filename: uniqueFilename,
originalFilename: photo.name,
filePath: filePath,
fileSize: photo.size,
mimeType: photo.type,
status: 'pending',
},
});
savedPhotos.push({
id: pendingPhoto.id,
filename: photo.name,
size: photo.size,
});
} catch (error: any) {
console.error(`Error saving photo ${photo.name}:`, error);
saveErrors.push(`${photo.name}: ${error.message || 'Failed to save file'}`);
}
}
if (savedPhotos.length === 0) {
return NextResponse.json(
{
error: 'Failed to save any photos',
details: saveErrors.length > 0 ? saveErrors : ['Unknown error occurred']
},
{ status: 500 }
);
}
console.log(`[FILE UPLOAD] User ${userId} (${session.user.email}) submitted ${savedPhotos.length} file(s) for review`);
return NextResponse.json({
message: `Successfully submitted ${savedPhotos.length} file(s) for admin review`,
photos: savedPhotos,
errors: errors.length > 0 || saveErrors.length > 0
? [...errors, ...saveErrors]
: undefined,
});
} catch (error: any) {
console.error('Error uploading photos:', error);
return NextResponse.json(
{ error: 'Failed to upload files', details: error.message },
{ status: 500 }
);
}
}