feat: Add new scripts and update project structure for database management and user authentication

This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
This commit is contained in:
Tanya
2026-01-06 13:53:24 -05:00
parent 713584dc04
commit de2144be2a
175 changed files with 35854 additions and 0 deletions
@@ -0,0 +1,155 @@
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.NEXTAUTH_SECRET,
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
if (!credentials?.email || !credentials?.password) {
console.log('[AUTH] Missing credentials');
return null;
}
console.log('[AUTH] Attempting to find user:', credentials.email);
const user = await prismaAuth.user.findUnique({
where: { email: credentials.email as string },
select: {
id: true,
email: true,
name: true,
passwordHash: true,
isAdmin: true,
hasWriteAccess: true,
emailVerified: true,
isActive: true,
},
});
if (!user) {
console.log('[AUTH] User not found:', credentials.email);
return null;
}
console.log('[AUTH] User found, checking password...');
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isPasswordValid) {
console.log('[AUTH] Invalid password for user:', credentials.email);
return null;
}
// Check if email is verified
if (!user.emailVerified) {
console.log('[AUTH] Email not verified for user:', credentials.email);
return null; // Return null to indicate failed login
}
// Check if user is active (treat null/undefined as true)
if (user.isActive === false) {
console.log('[AUTH] User is inactive:', credentials.email);
return null; // Return null to indicate failed login
}
console.log('[AUTH] Login successful for:', credentials.email);
return {
id: user.id.toString(),
email: user.email,
name: user.name || undefined,
isAdmin: user.isAdmin,
hasWriteAccess: user.hasWriteAccess,
};
} catch (error: any) {
console.error('[AUTH] Error during authorization:', error);
return null;
}
},
}),
],
pages: {
signIn: '/login',
signOut: '/',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours in seconds
updateAge: 1 * 60 * 60, // Refresh session every 1 hour (more frequent validation)
},
jwt: {
maxAge: 24 * 60 * 60, // 24 hours in seconds
},
callbacks: {
async jwt({ token, user, trigger }) {
// Set expiration time when user first logs in
if (user) {
token.id = user.id;
token.email = user.email;
token.isAdmin = user.isAdmin;
token.hasWriteAccess = user.hasWriteAccess;
token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
}
// Refresh user data from database on token refresh to get latest hasWriteAccess and isActive
// This ensures permissions are up-to-date even if granted after login
if (token.email && !user) {
try {
const dbUser = await prismaAuth.user.findUnique({
where: { email: token.email as string },
select: {
id: true,
email: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
},
});
if (dbUser) {
// Check if user is still active (treat null/undefined as true)
if (dbUser.isActive === false) {
// User was deactivated, invalidate token
return null as any;
}
token.id = dbUser.id.toString();
token.isAdmin = dbUser.isAdmin;
token.hasWriteAccess = dbUser.hasWriteAccess;
}
} catch (error) {
console.error('[AUTH] Error refreshing user data:', error);
// Continue with existing token data if refresh fails
}
}
return token;
},
async session({ session, token }) {
// If token is null or expired, return null session to force logout
if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) {
return null as any;
}
if (session.user) {
session.user.id = token.id as string;
session.user.email = token.email as string;
session.user.isAdmin = token.isAdmin as boolean;
session.user.hasWriteAccess = token.hasWriteAccess as boolean;
}
return session;
},
},
});
export const { GET, POST } = handlers;
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
select: {
id: true,
email: true,
passwordHash: true,
emailVerified: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ verified: false, exists: false },
{ status: 200 }
);
}
// Check if user is active (treat null/undefined as true)
if (user.isActive === false) {
return NextResponse.json(
{ verified: false, exists: true, passwordValid: false, active: false },
{ status: 200 }
);
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return NextResponse.json(
{ verified: false, exists: true, passwordValid: false },
{ status: 200 }
);
}
// Return verification status
return NextResponse.json(
{
verified: user.emailVerified,
exists: true,
passwordValid: true
},
{ status: 200 }
);
} catch (error: any) {
console.error('Error checking verification:', error);
return NextResponse.json(
{ error: 'Failed to check verification status' },
{ status: 500 }
);
}
}
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { generatePasswordResetToken, sendPasswordResetEmail } from '@/lib/email';
import { isValidEmail } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
});
// Don't reveal if user exists or not for security
// Always return success message
if (!user) {
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
}
// Check if user is active
if (user.isActive === false) {
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
}
// Generate password reset token
const resetToken = generatePasswordResetToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 1); // Token expires in 1 hour
// Update user with reset token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: resetToken,
passwordResetTokenExpiry: tokenExpiry,
},
});
// Send password reset email
try {
console.log('[FORGOT-PASSWORD] Attempting to send password reset email to:', user.email);
await sendPasswordResetEmail(user.email, user.name, resetToken);
console.log('[FORGOT-PASSWORD] Password reset email sent successfully to:', user.email);
} catch (emailError: any) {
console.error('[FORGOT-PASSWORD] Error sending password reset email:', emailError);
console.error('[FORGOT-PASSWORD] Error details:', {
message: emailError?.message,
name: emailError?.name,
response: emailError?.response,
statusCode: emailError?.statusCode,
});
// Clear the token if email fails
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{
error: 'Failed to send password reset email',
details: emailError?.message || 'Unknown error'
},
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error processing password reset request:', error);
return NextResponse.json(
{ error: 'Failed to process password reset request' },
{ status: 500 }
);
}
}
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { generateEmailConfirmationToken, sendEmailConfirmation } from '@/lib/email';
import { isValidEmail } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, name } = body;
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Email, password, and name are required' },
{ status: 400 }
);
}
if (name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name cannot be empty' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prismaAuth.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Generate email confirmation token
const confirmationToken = generateEmailConfirmationToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours
// Create user (without write access by default, email not verified)
const user = await prismaAuth.user.create({
data: {
email,
passwordHash,
name: name.trim(),
hasWriteAccess: false, // New users don't have write access by default
emailVerified: false,
emailConfirmationToken: confirmationToken,
emailConfirmationTokenExpiry: tokenExpiry,
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
// Send confirmation email
try {
await sendEmailConfirmation(email, name.trim(), confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
// Don't fail registration if email fails, but log it
// User can request a resend later
}
return NextResponse.json(
{
message: 'User created successfully. Please check your email to confirm your account.',
user,
requiresEmailConfirmation: true
},
{ status: 201 }
);
} catch (error: any) {
console.error('Error registering user:', error);
return NextResponse.json(
{ error: 'Failed to register user', details: error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { generateEmailConfirmationToken, sendEmailConfirmationResend } from '@/lib/email';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
});
if (!user) {
// Don't reveal if user exists or not for security
return NextResponse.json(
{ message: 'If an account with that email exists, a confirmation email has been sent.' },
{ status: 200 }
);
}
// If already verified, don't send another email
if (user.emailVerified) {
return NextResponse.json(
{ message: 'Email is already verified.' },
{ status: 200 }
);
}
// Generate new token
const confirmationToken = generateEmailConfirmationToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours
// Update user with new token
await prismaAuth.user.update({
where: { id: user.id },
data: {
emailConfirmationToken: confirmationToken,
emailConfirmationTokenExpiry: tokenExpiry,
},
});
// Send confirmation email
try {
await sendEmailConfirmationResend(user.email, user.name, confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
return NextResponse.json(
{ error: 'Failed to send confirmation email' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Confirmation email has been sent. Please check your inbox.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error resending confirmation email:', error);
return NextResponse.json(
{ error: 'Failed to resend confirmation email', details: error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, password } = body;
if (!token || !password) {
return NextResponse.json(
{ error: 'Token and password are required' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { passwordResetToken: token },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Check if token has expired
if (user.passwordResetTokenExpiry && user.passwordResetTokenExpiry < new Date()) {
// Clear expired token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{ error: 'Reset token has expired. Please request a new password reset.' },
{ status: 400 }
);
}
// Hash new password
const passwordHash = await bcrypt.hash(password, 10);
// Update password and clear reset token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{ message: 'Password has been reset successfully. You can now sign in with your new password.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error resetting password:', error);
return NextResponse.json(
{ error: 'Failed to reset password' },
{ status: 500 }
);
}
}
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(
new URL('/login?error=missing_token', request.url)
);
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { emailConfirmationToken: token },
});
if (!user) {
return NextResponse.redirect(
new URL('/login?error=invalid_token', request.url)
);
}
// Check if token has expired
if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) {
return NextResponse.redirect(
new URL('/login?error=token_expired', request.url)
);
}
// Check if already verified
if (user.emailVerified) {
return NextResponse.redirect(
new URL('/login?message=already_verified', request.url)
);
}
// Verify the email
await prismaAuth.user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailConfirmationToken: null,
emailConfirmationTokenExpiry: null,
},
});
// Redirect to login with success message
return NextResponse.redirect(
new URL('/login?verified=true', request.url)
);
} catch (error: any) {
console.error('Error verifying email:', error);
return NextResponse.redirect(
new URL('/login?error=verification_failed', request.url)
);
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
// Debug endpoint to check session
export async function GET(request: NextRequest) {
try {
const session = await auth();
return NextResponse.json({
hasSession: !!session,
user: session?.user || null,
userId: session?.user?.id || null,
isAdmin: session?.user?.isAdmin || false,
hasWriteAccess: session?.user?.hasWriteAccess || false,
}, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ error: 'Failed to get session', details: error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,174 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
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 identify faces.' },
{ status: 401 }
);
}
// Check write access
if (!session.user.hasWriteAccess) {
return NextResponse.json(
{ error: 'Write access required. You need write access to identify faces. Please contact an administrator.' },
{ status: 403 }
);
}
const { id } = await params;
const faceId = parseInt(id, 10);
if (isNaN(faceId)) {
return NextResponse.json(
{ error: 'Invalid face ID' },
{ status: 400 }
);
}
const body = await request.json();
const { personId, firstName, lastName, middleName, maidenName, dateOfBirth } = body;
let finalFirstName: string;
let finalLastName: string;
let finalMiddleName: string | null = null;
let finalMaidenName: string | null = null;
let finalDateOfBirth: Date | null = null;
// If personId is provided, fetch person data from database
if (personId) {
const person = await prisma.person.findUnique({
where: { id: parseInt(personId, 10) },
});
if (!person) {
return NextResponse.json(
{ error: 'Person not found' },
{ status: 404 }
);
}
finalFirstName = person.first_name;
finalLastName = person.last_name;
finalMiddleName = person.middle_name;
finalMaidenName = person.maiden_name;
finalDateOfBirth = person.date_of_birth;
} else {
// Validate required fields for new person
if (!firstName || !lastName) {
return NextResponse.json(
{ error: 'First name and last name are required' },
{ status: 400 }
);
}
finalFirstName = firstName;
finalLastName = lastName;
finalMiddleName = middleName || null;
finalMaidenName = maidenName || null;
// Parse date of birth if provided
const dob = dateOfBirth ? new Date(dateOfBirth) : null;
if (dateOfBirth && dob && isNaN(dob.getTime())) {
return NextResponse.json(
{ error: 'Invalid date of birth' },
{ status: 400 }
);
}
finalDateOfBirth = dob;
}
// Check if face exists (use read client for this - from punimtag database)
const face = await prisma.face.findUnique({
where: { id: faceId },
include: { Person: true },
});
if (!face) {
return NextResponse.json(
{ error: 'Face not found' },
{ status: 404 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user session' },
{ status: 401 }
);
}
// Check if there's already a pending identification for this face by this user
// Use auth client (connects to punimtag_auth database)
const existingPending = await prismaAuth.pendingIdentification.findFirst({
where: {
faceId,
userId,
status: 'pending',
},
});
if (existingPending) {
// Update existing pending identification
const updated = await prismaAuth.pendingIdentification.update({
where: { id: existingPending.id },
data: {
firstName: finalFirstName,
lastName: finalLastName,
middleName: finalMiddleName,
maidenName: finalMaidenName,
dateOfBirth: finalDateOfBirth,
},
});
return NextResponse.json({
message: 'Identification updated and pending approval',
pendingIdentification: updated,
});
}
// Create new pending identification
const pendingIdentification = await prismaAuth.pendingIdentification.create({
data: {
faceId,
userId,
firstName: finalFirstName,
lastName: finalLastName,
middleName: finalMiddleName,
maidenName: finalMaidenName,
dateOfBirth: finalDateOfBirth,
status: 'pending',
},
});
return NextResponse.json({
message: 'Identification submitted and pending approval',
pendingIdentification,
});
} catch (error: any) {
console.error('Error identifying face:', error);
// Handle unique constraint violation
if (error.code === 'P2002') {
return NextResponse.json(
{ error: 'A person with these details already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to identify face', details: error.message },
{ status: 500 }
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* Health check endpoint that verifies database connectivity and permissions
* This runs automatically and can help detect permission issues early
*/
export async function GET() {
const checks: Record<string, { status: 'ok' | 'error'; message: string }> = {};
// Check database connection
try {
await prisma.$connect();
checks.database_connection = {
status: 'ok',
message: 'Database connection successful',
};
} catch (error: any) {
checks.database_connection = {
status: 'error',
message: `Database connection failed: ${error.message}`,
};
return NextResponse.json(
{
status: 'error',
checks,
message: 'Database health check failed',
},
{ status: 503 }
);
}
// Check permissions on key tables
const tables = [
{ name: 'photos', query: () => prisma.photo.findFirst() },
{ name: 'people', query: () => prisma.person.findFirst() },
{ name: 'faces', query: () => prisma.face.findFirst() },
{ name: 'tags', query: () => prisma.tag.findFirst() },
];
for (const table of tables) {
try {
await table.query();
checks[`table_${table.name}`] = {
status: 'ok',
message: `SELECT permission on ${table.name} table is OK`,
};
} catch (error: any) {
if (error.message?.includes('permission denied')) {
checks[`table_${table.name}`] = {
status: 'error',
message: `Permission denied on ${table.name} table. Run grant_readonly_permissions.sql as superuser.`,
};
} else {
checks[`table_${table.name}`] = {
status: 'error',
message: `Error accessing ${table.name}: ${error.message}`,
};
}
}
}
const hasErrors = Object.values(checks).some((check) => check.status === 'error');
return NextResponse.json(
{
status: hasErrors ? 'error' : 'ok',
checks,
timestamp: new Date().toISOString(),
...(hasErrors && {
fixInstructions: {
message: 'To fix permission errors, run as PostgreSQL superuser:',
command: 'psql -U postgres -d punimtag -f grant_readonly_permissions.sql',
},
}),
},
{ status: hasErrors ? 503 : 200 }
);
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const people = await prisma.person.findMany({
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
});
// Transform snake_case to camelCase for frontend
const transformedPeople = people.map((person) => ({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
middleName: person.middle_name,
maidenName: person.maiden_name,
dateOfBirth: person.date_of_birth,
createdDate: person.created_date,
}));
return NextResponse.json({ people: transformedPeople }, { status: 200 });
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
const people = await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
// Transform snake_case to camelCase for frontend
const transformedPeople = people.map((person) => ({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
middleName: null,
maidenName: null,
dateOfBirth: null,
createdDate: null,
}));
return NextResponse.json({ people: transformedPeople }, { status: 200 });
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
return NextResponse.json(
{ error: 'Failed to fetch people', details: fallbackError.message },
{ status: 500 }
);
}
}
console.error('Error fetching people:', error);
return NextResponse.json(
{ error: 'Failed to fetch people', details: error.message },
{ status: 500 }
);
}
}
+394
View File
@@ -0,0 +1,394 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { serializePhotos } from '@/lib/serialize';
import { auth } from '@/app/api/auth/[...nextauth]/route';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// Parse query parameters
const people = searchParams.get('people')?.split(',').filter(Boolean).map(Number) || [];
const peopleMode = (searchParams.get('peopleMode') || 'any') as 'any' | 'all';
const tags = searchParams.get('tags')?.split(',').filter(Boolean).map(Number) || [];
const tagsMode = (searchParams.get('tagsMode') || 'any') as 'any' | 'all';
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const mediaType = (searchParams.get('mediaType') || 'all') as 'all' | 'photos' | 'videos';
const favoritesOnly = searchParams.get('favoritesOnly') === 'true';
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '30', 10);
const skip = (page - 1) * pageSize;
// Get user session for favorites filter
const session = await auth();
let favoritePhotoIds: number[] = [];
if (favoritesOnly && session?.user?.id) {
const userId = parseInt(session.user.id, 10);
if (!isNaN(userId)) {
try {
const favorites = await prismaAuth.photoFavorite.findMany({
where: { userId },
select: { photoId: true },
});
favoritePhotoIds = favorites.map(f => f.photoId);
// If user has no favorites, return empty result
if (favoritePhotoIds.length === 0) {
return NextResponse.json({
photos: [],
total: 0,
page,
pageSize,
totalPages: 0,
});
}
} 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');
} else {
console.error('Error fetching favorites:', error);
}
// If favorites table doesn't exist or error, treat as no favorites
if (favoritesOnly) {
return NextResponse.json({
photos: [],
total: 0,
page,
pageSize,
totalPages: 0,
});
}
}
}
}
// Build where clause
const where: any = {
processed: true,
};
// Media type filter
if (mediaType !== 'all') {
if (mediaType === 'photos') {
where.media_type = 'image';
} else if (mediaType === 'videos') {
where.media_type = 'video';
}
}
// Date filter
if (dateFrom || dateTo) {
where.date_taken = {};
if (dateFrom) {
where.date_taken.gte = new Date(dateFrom);
}
if (dateTo) {
where.date_taken.lte = new Date(dateTo);
}
}
// People filter
if (people.length > 0) {
if (peopleMode === 'all') {
// Photo must have ALL selected people
where.AND = where.AND || [];
people.forEach((personId) => {
where.AND.push({
Face: {
some: {
person_id: personId,
},
},
});
});
} else {
// Photo has ANY of the selected people (default)
where.Face = {
some: {
person_id: { in: people },
},
};
}
}
// Tags filter
if (tags.length > 0) {
if (tagsMode === 'all') {
// Photo must have ALL selected tags
where.AND = where.AND || [];
tags.forEach((tagId) => {
where.AND.push({
PhotoTagLinkage: {
some: {
tag_id: tagId,
},
},
});
});
} else {
// Photo has ANY of the selected tags (default)
where.PhotoTagLinkage = {
some: {
tag_id: { in: tags },
},
};
}
}
// Favorites filter
if (favoritesOnly && favoritePhotoIds.length > 0) {
where.id = { in: favoritePhotoIds };
} else if (favoritesOnly && favoritePhotoIds.length === 0) {
// User has no favorites, return empty (already handled above, but keep for safety)
where.id = { in: [] };
}
// Execute query - load photos and relations separately
// Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues
let photosBase: any[];
let total: number;
try {
// Build WHERE clause for raw SQL
const whereConditions: string[] = ['processed = true'];
const params: any[] = [];
let paramIndex = 1; // PostgreSQL uses $1, $2, etc.
if (mediaType !== 'all') {
if (mediaType === 'photos') {
whereConditions.push(`media_type = $${paramIndex}`);
params.push('image');
paramIndex++;
} else if (mediaType === 'videos') {
whereConditions.push(`media_type = $${paramIndex}`);
params.push('video');
paramIndex++;
}
}
if (dateFrom || dateTo) {
if (dateFrom) {
whereConditions.push(`date_taken >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereConditions.push(`date_taken <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
}
// Handle people filter - embed IDs directly since they're safe integers
if (people.length > 0) {
const peopleIds = people.join(',');
whereConditions.push(`id IN (
SELECT DISTINCT photo_id FROM faces WHERE person_id IN (${peopleIds})
)`);
}
// Handle tags filter - embed IDs directly since they're safe integers
if (tags.length > 0) {
const tagIds = tags.join(',');
whereConditions.push(`id IN (
SELECT DISTINCT photo_id FROM phototaglinkage WHERE tag_id IN (${tagIds})
)`);
}
// Handle favorites filter - embed IDs directly since they're safe integers
if (favoritesOnly && favoritePhotoIds.length > 0) {
const favIds = favoritePhotoIds.join(',');
whereConditions.push(`id IN (${favIds})`);
} else if (favoritesOnly && favoritePhotoIds.length === 0) {
whereConditions.push('1 = 0'); // No favorites, return empty
}
const whereClause = whereConditions.join(' AND ');
// Build query parameters (LIMIT and OFFSET are embedded directly as they're safe integers)
const queryParams = [...params];
const countParams = [...params];
// Use raw query to read dates as strings
// Note: LIMIT and OFFSET are embedded directly since they're integers and safe
const [photosRaw, totalResult] = await Promise.all([
prisma.$queryRawUnsafe<Array<{
id: number;
path: string;
filename: string;
date_added: string;
date_taken: string | null;
processed: boolean;
media_type: string | null;
}>>(
`SELECT
id,
path,
filename,
date_added,
date_taken,
processed,
media_type
FROM photos
WHERE ${whereClause}
ORDER BY date_taken DESC, id DESC
LIMIT ${pageSize} OFFSET ${skip}`,
...queryParams
),
prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*) as count FROM photos WHERE ${whereClause}`,
...countParams
),
]);
// Convert date strings to Date objects
photosBase = photosRaw.map(photo => ({
id: photo.id,
path: photo.path,
filename: photo.filename,
date_added: new Date(photo.date_added),
date_taken: photo.date_taken ? new Date(photo.date_taken) : null,
processed: photo.processed,
media_type: photo.media_type,
}));
total = Number(totalResult[0].count);
} catch (error: any) {
console.error('Error loading photos:', error);
throw error;
}
// Load faces and tags separately
const photoIds = photosBase.map(p => p.id);
// Fetch faces
let faces: any[] = [];
try {
faces = await prisma.face.findMany({
where: { photo_id: { in: photoIds } },
select: {
id: true,
photo_id: true,
person_id: true,
location: true,
confidence: true,
quality_score: true,
is_primary_encoding: true,
detector_backend: true,
model_name: true,
face_confidence: true,
exif_orientation: true,
pose_mode: true,
yaw_angle: true,
pitch_angle: true,
roll_angle: true,
landmarks: true,
identified_by_user_id: true,
excluded: true,
Person: {
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
},
// Exclude encoding field (Bytes) to avoid P2023 conversion errors
},
});
} catch (faceError: any) {
if (faceError?.code === 'P2023' || faceError?.message?.includes('Conversion failed')) {
console.warn('Corrupted face data detected in search, skipping faces');
faces = [];
} else {
throw faceError;
}
}
// Fetch photo tag linkages with error handling
let photoTagLinkages: any[] = [];
try {
photoTagLinkages = await prisma.photoTagLinkage.findMany({
where: { photo_id: { in: photoIds } },
select: {
linkage_id: true,
photo_id: true,
tag_id: true,
linkage_type: true,
created_date: true,
Tag: {
select: {
id: true,
tag_name: true,
created_date: true,
},
},
},
});
} catch (linkageError: any) {
if (linkageError?.code === 'P2023' || linkageError?.message?.includes('Conversion failed')) {
console.warn('Corrupted photo tag linkage data detected, attempting fallback query');
try {
// Try with minimal fields
photoTagLinkages = await prisma.photoTagLinkage.findMany({
where: { photo_id: { in: photoIds } },
select: {
linkage_id: true,
photo_id: true,
tag_id: true,
// Exclude potentially corrupted fields
Tag: {
select: {
id: true,
tag_name: true,
// Exclude created_date if it's corrupted
},
},
},
});
} catch (fallbackError: any) {
console.error('Fallback photo tag linkage query also failed:', fallbackError);
// Return empty array as last resort to prevent API crash
photoTagLinkages = [];
}
} else {
throw linkageError;
}
}
// Combine the data manually
const photos = photosBase.map(photo => ({
...photo,
Face: faces.filter(face => face.photo_id === photo.id),
PhotoTagLinkage: photoTagLinkages.filter(link => link.photo_id === photo.id),
}));
return NextResponse.json({
photos: serializePhotos(photos),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
console.error('Search error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
console.error('Error details:', { errorMessage, errorStack, error });
return NextResponse.json(
{
error: 'Failed to search photos',
details: errorMessage,
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
},
{ status: 500 }
);
}
}
+324
View File
@@ -0,0 +1,324 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { isAdmin } from '@/lib/permissions';
import bcrypt from 'bcryptjs';
// PATCH /api/users/[id] - Update user (admin only)
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
const body = await request.json();
const { hasWriteAccess, name, password, email, isAdmin: isAdminValue, isActive } = body;
// Prevent users from removing their own admin status
const session = await import('@/app/api/auth/[...nextauth]/route').then(
(m) => m.auth()
);
if (session?.user?.id && parseInt(session.user.id, 10) === userId) {
if (isAdminValue === false) {
return NextResponse.json(
{ error: 'You cannot remove your own admin status' },
{ status: 400 }
);
}
}
// Build update data
const updateData: {
hasWriteAccess?: boolean;
name?: string;
passwordHash?: string;
email?: string;
isAdmin?: boolean;
isActive?: boolean;
} = {};
if (typeof hasWriteAccess === 'boolean') {
updateData.hasWriteAccess = hasWriteAccess;
}
if (typeof isAdminValue === 'boolean') {
updateData.isAdmin = isAdminValue;
}
if (typeof isActive === 'boolean') {
updateData.isActive = isActive;
}
if (name !== undefined) {
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name is required and cannot be empty' },
{ status: 400 }
);
}
updateData.name = name.trim();
}
if (email !== undefined) {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
updateData.email = email;
}
if (password) {
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
updateData.passwordHash = await bcrypt.hash(password, 10);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: 'No valid fields to update' },
{ status: 400 }
);
}
// Update user
const user = await prismaAuth.user.update({
where: { id: userId },
data: updateData,
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(
{ message: 'User updated successfully', user },
{ status: 200 }
);
} catch (error: any) {
console.error('Error updating user:', error);
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (error.code === 'P2002') {
// Unique constraint violation (likely email already exists)
return NextResponse.json(
{ error: 'Email already exists. Please use a different email address.' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to update user', details: error.message },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - Delete user (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Prevent deleting yourself
const session = await import('@/app/api/auth/[...nextauth]/route').then(
(m) => m.auth()
);
if (session?.user?.id && parseInt(session.user.id, 10) === userId) {
return NextResponse.json(
{ error: 'You cannot delete your own account' },
{ status: 400 }
);
}
// Check if user has any related records in other tables
let pendingIdentifications = 0;
let pendingPhotos = 0;
let inappropriatePhotoReports = 0;
let pendingLinkages = 0;
let photoFavorites = 0;
try {
[pendingIdentifications, pendingPhotos, inappropriatePhotoReports, pendingLinkages, photoFavorites] = await Promise.all([
prismaAuth.pendingIdentification.count({ where: { userId } }),
prismaAuth.pendingPhoto.count({ where: { userId } }),
prismaAuth.inappropriatePhotoReport.count({ where: { userId } }),
prismaAuth.pendingLinkage.count({ where: { userId } }),
prismaAuth.photoFavorite.count({ where: { userId } }),
]);
} catch (countError: any) {
console.error('Error counting related records:', countError);
// If counting fails, err on the side of caution and deactivate instead of delete
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
return NextResponse.json(
{
message: 'User deactivated successfully (error checking related records)',
deactivated: true
},
{ status: 200 }
);
}
console.log(`[DELETE User ${userId}] Related records:`, {
pendingIdentifications,
pendingPhotos,
inappropriatePhotoReports,
pendingLinkages,
photoFavorites,
});
// Ensure all counts are numbers and check explicitly
const counts = {
pendingIdentifications: Number(pendingIdentifications) || 0,
pendingPhotos: Number(pendingPhotos) || 0,
inappropriatePhotoReports: Number(inappropriatePhotoReports) || 0,
pendingLinkages: Number(pendingLinkages) || 0,
photoFavorites: Number(photoFavorites) || 0,
};
const hasRelatedRecords =
counts.pendingIdentifications > 0 ||
counts.pendingPhotos > 0 ||
counts.inappropriatePhotoReports > 0 ||
counts.pendingLinkages > 0 ||
counts.photoFavorites > 0;
console.log(`[DELETE User ${userId}] hasRelatedRecords:`, hasRelatedRecords, 'Counts:', counts);
if (hasRelatedRecords) {
console.log(`[DELETE User ${userId}] Deactivating user due to related records`);
// Set user as inactive instead of deleting
try {
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
console.log(`[DELETE User ${userId}] User deactivated successfully`);
} catch (updateError: any) {
console.error(`[DELETE User ${userId}] Error deactivating user:`, updateError);
throw updateError;
}
return NextResponse.json(
{
message: 'User deactivated successfully (user has related records in other tables)',
deactivated: true,
relatedRecords: {
pendingIdentifications: counts.pendingIdentifications,
pendingPhotos: counts.pendingPhotos,
inappropriatePhotoReports: counts.inappropriatePhotoReports,
pendingLinkages: counts.pendingLinkages,
photoFavorites: counts.photoFavorites,
}
},
{ status: 200 }
);
}
console.log(`[DELETE User ${userId}] No related records found, proceeding with deletion`);
// Double-check one more time before deleting (defensive programming)
const finalCheck = await Promise.all([
prismaAuth.pendingIdentification.count({ where: { userId } }),
prismaAuth.pendingPhoto.count({ where: { userId } }),
prismaAuth.inappropriatePhotoReport.count({ where: { userId } }),
prismaAuth.pendingLinkage.count({ where: { userId } }),
prismaAuth.photoFavorite.count({ where: { userId } }),
]);
const finalHasRelatedRecords = finalCheck.some(count => count > 0);
if (finalHasRelatedRecords) {
console.log(`[DELETE User ${userId}] Final check found related records, deactivating instead`);
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
return NextResponse.json(
{
message: 'User deactivated successfully (related records detected in final check)',
deactivated: true
},
{ status: 200 }
);
}
// No related records, safe to delete
console.log(`[DELETE User ${userId}] Confirmed no related records, deleting user`);
await prismaAuth.user.delete({
where: { id: userId },
});
console.log(`[DELETE User ${userId}] User deleted successfully`);
return NextResponse.json(
{ message: 'User deleted successfully' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error deleting user:', error);
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to delete user', details: error.message },
{ status: 500 }
);
}
}
+173
View File
@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { isAdmin } from '@/lib/permissions';
import bcrypt from 'bcryptjs';
import { isValidEmail } from '@/lib/utils';
// GET /api/users - List all users (admin only)
export async function GET(request: NextRequest) {
try {
console.log('[API /users] Request received');
// Check if user is admin
console.log('[API /users] Checking admin status...');
const admin = await isAdmin();
console.log('[API /users] Admin check result:', admin);
if (!admin) {
console.log('[API /users] Unauthorized - user is not admin');
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.', message: 'You must be an administrator to access this resource.' },
{ status: 403 }
);
}
console.log('[API /users] User is admin, fetching users from database...');
// Get filter from query parameters
const { searchParams } = new URL(request.url);
const statusFilter = searchParams.get('status'); // 'all', 'active', 'inactive'
// Build where clause based on filter
let whereClause: any = {};
if (statusFilter === 'active') {
whereClause = { NOT: { isActive: false } }; // Active only (treat null/undefined as active)
} else if (statusFilter === 'inactive') {
whereClause = { isActive: false }; // Inactive only
}
// If 'all' or no filter, don't add where clause (get all users)
const users = await prismaAuth.user.findMany({
where: whereClause,
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
console.log('[API /users] Successfully fetched', users.length, 'users');
return NextResponse.json({ users }, { status: 200 });
} catch (error: any) {
console.error('[API /users] Error:', error);
console.error('[API /users] Error stack:', error.stack);
return NextResponse.json(
{
error: 'Failed to fetch users',
details: error.message,
message: error.message || 'An unexpected error occurred while fetching users.'
},
{ status: 500 }
);
}
}
// POST /api/users - Create new user (admin only)
export async function POST(request: NextRequest) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const body = await request.json();
const {
email,
password,
name,
hasWriteAccess,
isAdmin: newUserIsAdmin,
} = body;
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Email, password, and name are required' },
{ status: 400 }
);
}
if (name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name cannot be empty' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prismaAuth.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user (admin-created users are automatically verified)
const user = await prismaAuth.user.create({
data: {
email,
passwordHash,
name: name.trim(),
hasWriteAccess: hasWriteAccess ?? false,
isAdmin: newUserIsAdmin ?? false,
emailVerified: true, // Admin-created users are automatically verified
emailConfirmationToken: null, // No confirmation token needed
emailConfirmationTokenExpiry: null, // No expiry needed
},
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(
{ message: 'User created successfully', user },
{ status: 201 }
);
} catch (error: any) {
console.error('Error creating user:', error);
return NextResponse.json(
{ error: 'Failed to create user', details: error.message },
{ status: 500 }
);
}
}