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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user