feat: Add photo management API endpoints for fetching, favoriting, reporting, and tagging photos #5

Merged
tanyar09 merged 1 commits from fix/photos-paths into dev 2026-01-21 14:49:48 -05:00
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 }
);
}
}