SEO: Fix structured data validation errors, delete static sitemap, and update indexing scripts

This commit is contained in:
Timo Knuth
2026-01-23 23:10:22 +01:00
parent f3637fc2fe
commit eef4855c1b
147 changed files with 24590 additions and 27027 deletions

View File

@@ -0,0 +1,218 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
// Check newsletter-admin cookie authentication
const cookieStore = cookies();
const adminCookie = cookieStore.get('newsletter-admin');
if (!adminCookie || adminCookie.value !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized - Admin login required' },
{ status: 401 }
);
}
// Get 30 days ago date
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get 7 days ago date
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Get start of current month
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
// Fetch all statistics in parallel
const [
totalUsers,
premiumUsers,
newUsersThisWeek,
newUsersThisMonth,
totalQRCodes,
dynamicQRCodes,
staticQRCodes,
totalScans,
dynamicQRCodesWithScans,
activeQRCodes,
newsletterSubscribers,
] = await Promise.all([
// Total users
db.user.count(),
// Premium users (PRO or BUSINESS)
db.user.count({
where: {
plan: {
in: ['PRO', 'BUSINESS'],
},
},
}),
// New users this week
db.user.count({
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
}),
// New users this month
db.user.count({
where: {
createdAt: {
gte: startOfMonth,
},
},
}),
// Total QR codes
db.qRCode.count(),
// Dynamic QR codes
db.qRCode.count({
where: {
type: 'DYNAMIC',
},
}),
// Static QR codes
db.qRCode.count({
where: {
type: 'STATIC',
},
}),
// Total scans
db.qRScan.count(),
// Get all dynamic QR codes with their scan counts
db.qRCode.findMany({
where: {
type: 'DYNAMIC',
},
include: {
_count: {
select: {
scans: true,
},
},
},
}),
// Active QR codes (scanned in last 30 days)
db.qRCode.findMany({
where: {
scans: {
some: {
ts: {
gte: thirtyDaysAgo,
},
},
},
},
distinct: ['id'],
}),
// Newsletter subscribers
db.newsletterSubscription.count({
where: {
status: 'subscribed',
},
}),
]);
// Calculate dynamic QR scans
const dynamicQRScans = dynamicQRCodesWithScans.reduce(
(total, qr) => total + qr._count.scans,
0
);
// Calculate average scans per dynamic QR
const avgScansPerDynamicQR =
dynamicQRCodes > 0 ? (dynamicQRScans / dynamicQRCodes).toFixed(1) : '0';
// Get top 5 most scanned QR codes
const topQRCodes = await db.qRCode.findMany({
take: 5,
include: {
_count: {
select: {
scans: true,
},
},
user: {
select: {
email: true,
name: true,
},
},
},
orderBy: {
scans: {
_count: 'desc',
},
},
});
// Get recent users
const recentUsers = await db.user.findMany({
take: 5,
orderBy: {
createdAt: 'desc',
},
select: {
email: true,
name: true,
plan: true,
createdAt: true,
},
});
return NextResponse.json({
users: {
total: totalUsers,
premium: premiumUsers,
newThisWeek: newUsersThisWeek,
newThisMonth: newUsersThisMonth,
recent: recentUsers,
},
qrCodes: {
total: totalQRCodes,
dynamic: dynamicQRCodes,
static: staticQRCodes,
active: activeQRCodes.length,
},
scans: {
total: totalScans,
dynamicOnly: dynamicQRScans,
avgPerDynamicQR: avgScansPerDynamicQR,
},
newsletter: {
subscribers: newsletterSubscribers,
},
topQRCodes: topQRCodes.map((qr) => ({
id: qr.id,
title: qr.title,
type: qr.type,
scans: qr._count.scans,
owner: qr.user.name || qr.user.email,
createdAt: qr.createdAt,
})),
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch statistics' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,288 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
export const dynamic = 'force-dynamic';
// Helper function to calculate trend with proper edge case handling
function calculateTrend(current: number, previous: number): TrendData {
// Handle edge case: no data in either period
if (previous === 0 && current === 0) {
return { trend: 'flat', percentage: 0 };
}
// Handle new growth from zero - mark as "new" to distinguish from actual 100% growth
if (previous === 0 && current > 0) {
return { trend: 'up', percentage: 100, isNew: true };
}
// Calculate actual percentage change
const change = ((current - previous) / previous) * 100;
const roundedChange = Math.round(change);
// Determine trend direction (use threshold of 5% to filter noise)
let trend: 'up' | 'down' | 'flat';
if (roundedChange > 5) {
trend = 'up';
} else if (roundedChange < -5) {
trend = 'down';
} else {
trend = 'flat';
}
return {
trend,
percentage: Math.abs(roundedChange),
isNegative: roundedChange < 0
};
}
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.ANALYTICS);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get date range from query params (default: last 30 days)
const { searchParams } = request.nextUrl;
const range = searchParams.get('range') || '30';
const daysInRange = parseInt(range, 10);
// Standardize to week (7 days) or month (30 days) for clear comparison labels
const comparisonDays = daysInRange <= 7 ? 7 : 30;
const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month';
// Calculate current and previous period dates
const now = new Date();
const currentPeriodStart = new Date();
currentPeriodStart.setDate(now.getDate() - comparisonDays);
const previousPeriodEnd = new Date(currentPeriodStart);
const previousPeriodStart = new Date(previousPeriodEnd);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays);
// Get user's QR codes with scans filtered by period
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
scans: {
where: {
ts: {
gte: currentPeriodStart,
},
},
},
},
});
// Get previous period scans for comparison
const qrCodesWithPreviousScans = await db.qRCode.findMany({
where: { userId },
include: {
scans: {
where: {
ts: {
gte: previousPeriodStart,
lt: previousPeriodEnd,
},
},
},
},
});
// Calculate current period stats
const totalScans = qrCodes.reduce((sum, qr) => sum + qr.scans.length, 0);
const uniqueScans = qrCodes.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate previous period stats for comparison
const previousTotalScans = qrCodesWithPreviousScans.reduce((sum, qr) => sum + qr.scans.length, 0);
const previousUniqueScans = qrCodesWithPreviousScans.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate average scans per QR code (only count QR codes with scans)
const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length;
const avgScansPerQR = qrCodesWithScans > 0
? Math.round(totalScans / qrCodesWithScans)
: 0;
// Calculate previous period average scans per QR
const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length;
const previousAvgScansPerQR = previousQrCodesWithScans > 0
? Math.round(previousTotalScans / previousQrCodesWithScans)
: 0;
// Calculate trends
const scansTrend = calculateTrend(totalScans, previousTotalScans);
// New Conversion Rate Logic: (Unique Scans / Total Scans) * 100
// This represents "Engagement Efficiency" - how many scans are from fresh users
const currentConversion = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
const previousConversion = previousTotalScans > 0
? Math.round((previousUniqueScans / previousTotalScans) * 100)
: 0;
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const device = scan.device || 'unknown';
acc[device] = (acc[device] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
: 0;
// Country stats (current period)
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Country stats (previous period)
const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topCountry = Object.entries(countryStats)
.sort(([, a], [, b]) => b - a)[0];
// Daily scan counts for chart (current period)
const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => {
const date = new Date(scan.ts).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Generate last 7 days for sparkline
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (6 - i));
return date.toISOString().split('T')[0];
});
// QR performance (only show DYNAMIC QR codes since STATIC don't track scans)
const qrPerformance = qrCodes
.filter(qr => qr.type === 'DYNAMIC')
.map(qr => {
const currentTotal = qr.scans.length;
const currentUnique = qr.scans.filter(s => s.isUnique).length;
// Find previous period data for this QR code
const previousQR = qrCodesWithPreviousScans.find(prev => prev.id === qr.id);
const previousTotal = previousQR ? previousQR.scans.length : 0;
// Calculate trend
const trendData = calculateTrend(currentTotal, previousTotal);
// Calculate sparkline data (scans per day for last 7 days)
const sparklineData = last7Days.map(date => {
return qr.scans.filter(s =>
new Date(s.ts).toISOString().split('T')[0] === date
).length;
});
// Find last scanned date
const lastScanned = qr.scans.length > 0
? new Date(Math.max(...qr.scans.map(s => new Date(s.ts).getTime())))
: null;
return {
id: qr.id,
title: qr.title,
type: qr.type,
totalScans: currentTotal,
uniqueScans: currentUnique,
conversion: currentTotal > 0
? Math.round((currentUnique / currentTotal) * 100)
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
sparkline: sparklineData,
lastScanned: lastScanned?.toISOString() || null,
...(trendData.isNew && { isNew: true }),
};
})
.sort((a, b) => b.totalScans - a.totalScans);
return NextResponse.json({
summary: {
totalScans,
uniqueScans,
avgScansPerQR,
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
scansTrend,
avgScansTrend,
comparisonPeriod,
comparisonDays,
},
deviceStats,
countryStats: Object.entries(countryStats)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([country, count]) => {
const previousCount = previousCountryStats[country] || 0;
const trendData = calculateTrend(count, previousCount);
return {
country,
count,
percentage: totalScans > 0
? Math.round((count / totalScans) * 100)
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
}),
dailyScans,
qrPerformance: qrPerformance.slice(0, 10),
});
} catch (error) {
console.error('Error fetching analytics:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { sendPasswordResetEmail } from '@/lib/email';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
try {
// Verify CSRF token
const csrfCheck = csrfProtection(req);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error || 'Invalid CSRF token' },
{ status: 403 }
);
}
const body = await req.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
// Find user by email
const user = await db.user.findUnique({
where: { email: email.toLowerCase() },
});
// For security, always return success even if email doesn't exist
// This prevents email enumeration attacks
if (!user) {
console.log('Password reset requested for non-existent email:', email);
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset link has been sent.' },
{ status: 200 }
);
}
// Generate secure random token
const resetToken = crypto.randomBytes(32).toString('hex');
// Set token expiration to 1 hour from now
const resetExpires = new Date(Date.now() + 3600000); // 1 hour
// Save token and expiration to database
await db.user.update({
where: { id: user.id },
data: {
resetPasswordToken: resetToken,
resetPasswordExpires: resetExpires,
},
});
// Send password reset email
try {
await sendPasswordResetEmail(email, resetToken);
} catch (emailError) {
console.error('Error sending password reset email:', emailError);
return NextResponse.json(
{ error: 'Failed to send reset email. Please try again later.' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Password reset email sent successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error in forgot-password route:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
// If no code, redirect to Google OAuth
if (!code) {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
if (!googleClientId) {
return NextResponse.json(
{ error: 'Google Client ID not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile';
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
return NextResponse.redirect(googleAuthUrl);
}
// Handle callback with code
try {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!googleClientId || !googleClientSecret) {
return NextResponse.json(
{ error: 'Google OAuth not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: googleClientId,
client_secret: googleClientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenResponse.ok) {
throw new Error('Failed to exchange code for tokens');
}
const tokens = await tokenResponse.json();
// Get user info from Google
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
if (!userInfoResponse.ok) {
throw new Error('Failed to get user info');
}
const userInfo = await userInfoResponse.json();
// Check if user exists in database
let user = await db.user.findUnique({
where: { email: userInfo.email },
});
const isNewUser = !user;
// Create user if they don't exist
if (!user) {
user = await db.user.create({
data: {
email: userInfo.email,
name: userInfo.name || userInfo.email.split('@')[0],
image: userInfo.picture,
emailVerified: new Date(), // Google already verified the email
password: null, // OAuth users don't need a password
},
});
// Create Account entry for the OAuth provider
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
} else {
// Update existing account tokens
const existingAccount = await db.account.findUnique({
where: {
provider_providerAccountId: {
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
},
},
});
if (existingAccount) {
await db.account.update({
where: { id: existingAccount.id },
data: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
},
});
} else {
// Create Account entry if it doesn't exist
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
}
}
// Set authentication cookie
cookies().set('userId', user.id, getAuthCookieOptions());
// Redirect to dashboard with tracking params
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
redirectUrl.searchParams.set('authMethod', 'google');
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
return NextResponse.redirect(redirectUrl.toString());
} catch (error) {
console.error('Google OAuth error:', error);
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import bcrypt from 'bcryptjs';
export async function POST(req: NextRequest) {
try {
// Verify CSRF token
const csrfCheck = csrfProtection(req);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error || 'Invalid CSRF token' },
{ status: 403 }
);
}
const body = await req.json();
const { token, password } = body;
if (!token || !password) {
return NextResponse.json(
{ error: 'Token and password are required' },
{ status: 400 }
);
}
// Validate password length
if (password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters long' },
{ status: 400 }
);
}
// Find user with this reset token
const user = await db.user.findUnique({
where: { resetPasswordToken: token },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Check if token has expired
if (!user.resetPasswordExpires || user.resetPasswordExpires < new Date()) {
// Clear expired token
await db.user.update({
where: { id: user.id },
data: {
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
return NextResponse.json(
{ error: 'Reset token has expired. Please request a new password reset link.' },
{ status: 400 }
);
}
// Hash the new password
const hashedPassword = await bcrypt.hash(password, 10);
// Update user's password and clear reset token
await db.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
console.log('Password successfully reset for user:', user.email);
return NextResponse.json(
{ message: 'Password reset successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error in reset-password route:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
// Rate Limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.SIGNUP);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many signup attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(signupSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { name, email, password } = validation.data;
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
// Create response
const response = NextResponse.json({
success: true,
user: {
id: user.id,
name: user.name,
email: user.email,
plan: 'FREE',
},
});
// Set cookie for auto-login after signup
response.cookies.set('userId', user.id, getAuthCookieOptions());
return response;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
// Rate Limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.LOGIN);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many login attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(loginSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { email, password } = validation.data;
// Find user
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Verify password
const isValid = await bcrypt.compare(password, user.password || '');
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Set cookie
cookies().set('userId', user.id, getAuthCookieOptions());
return NextResponse.json({
success: true,
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getOrCreateCsrfToken } from '@/lib/csrf';
export const dynamic = 'force-dynamic';
/**
* GET /api/csrf
* Returns a CSRF token for the current session
*/
export async function GET() {
const token = getOrCreateCsrfToken();
return NextResponse.json({ csrfToken: token });
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { slug, rating, comment } = body;
if (!slug || !rating) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Find the QR code
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: { id: true },
});
if (!qrCode) {
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
}
// Log feedback as a scan with additional data
// In a full implementation, you'd have a Feedback model
// For now, we'll store it in QRScan with special markers
await db.qRScan.create({
data: {
qrId: qrCode.id,
ipHash: 'feedback',
userAgent: `rating:${rating}|comment:${comment?.substring(0, 200) || ''}`,
device: 'feedback',
isUnique: true,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error submitting feedback:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { Prisma } from '@prisma/client';
interface LeadInput {
email: string;
source?: string;
reprintCost?: number;
updatesPerYear?: number;
annualSavings?: number;
}
export async function POST(request: Request) {
try {
const body: LeadInput = await request.json();
const { email, source, reprintCost, updatesPerYear, annualSavings } = body;
if (!email || !email.includes('@')) {
return NextResponse.json(
{ error: 'Valid email is required' },
{ status: 400 }
);
}
// Use typed db client - keeping (db as any) temporarily if types are missing locally,
// but cleaner code should use db.lead directly.
// We will trust the user to run `npm run build` which runs `prisma generate`.
const lead = await db.lead.create({
data: {
email: email.toLowerCase().trim(),
source: source || 'reprint-calculator',
reprintCost: reprintCost ? Number(reprintCost) : null,
updatesPerYear: updatesPerYear ? Number(updatesPerYear) : null,
annualSavings: annualSavings ? Number(annualSavings) : null,
},
});
return NextResponse.json({ success: true, id: lead.id });
} catch (error) {
console.error('Error saving lead:', error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2021') {
console.error('CRITICAL: Table "Lead" does not exist in the database. Run "npx prisma migrate deploy" to fix this.');
return NextResponse.json(
{
error: 'Database configuration error',
details: 'Missing database table. Please run migrations.'
},
{ status: 500 }
);
}
}
// Return the actual error message for debugging purposes
return NextResponse.json(
{
error: 'Failed to save lead',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
export async function GET() {
try {
const [leads, total] = await Promise.all([
db.lead.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
}),
(db as any).lead.count(),
]);
return NextResponse.json({
total,
recent: leads.map((lead: {
id: string;
email: string;
source: string;
reprintCost: number | null;
updatesPerYear: number | null;
annualSavings: number | null;
createdAt: Date;
}) => ({
id: lead.id,
email: lead.email,
source: lead.source,
reprintCost: lead.reprintCost,
updatesPerYear: lead.updatesPerYear,
annualSavings: lead.annualSavings,
createdAt: lead.createdAt.toISOString(),
})),
});
} catch (error) {
console.error('Error fetching leads:', error);
return NextResponse.json(
{ error: 'Failed to fetch leads' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
/**
* POST /api/newsletter/admin-login
* Simple admin login for newsletter management (no CSRF required)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
// Validate input
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
return NextResponse.json(
{ error: 'Access denied. Only authorized accounts can access this area.' },
{ status: 403 }
);
}
// Verify password with hardcoded value
if (password !== ALLOWED_ADMIN_PASSWORD) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Set auth cookie with a simple session identifier
const response = NextResponse.json({
success: true,
message: 'Login successful',
});
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
return response;
} catch (error) {
console.error('Newsletter admin login error:', error);
return NextResponse.json(
{ error: 'Login failed. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { sendAIFeatureLaunchEmail } from '@/lib/email';
import { rateLimit, RateLimits } from '@/lib/rateLimit';
/**
* POST /api/newsletter/broadcast
* Send AI feature launch email to all subscribed users
* PROTECTED: Only authenticated users can access (you may want to add admin check)
*/
export async function POST(request: NextRequest) {
try {
// Check authentication using newsletter-admin cookie
const adminCookie = cookies().get('newsletter-admin')?.value;
if (adminCookie !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized. Please log in.' },
{ status: 401 }
);
}
// Optional: Add admin check here
// const user = await db.user.findUnique({ where: { id: userId } });
// if (user?.role !== 'ADMIN') {
// return NextResponse.json({ error: 'Forbidden. Admin access required.' }, { status: 403 });
// }
// Rate limiting (prevent accidental spam)
const rateLimitResult = rateLimit('newsletter-admin', {
name: 'newsletter-broadcast',
maxRequests: 2, // Only 2 broadcasts per hour
windowSeconds: 60 * 60,
});
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many broadcast attempts. Please wait before trying again.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{ status: 429 }
);
}
// Get all subscribed users
const subscribers = await db.newsletterSubscription.findMany({
where: {
status: 'subscribed',
},
select: {
email: true,
},
});
if (subscribers.length === 0) {
return NextResponse.json({
success: true,
message: 'No subscribers found',
sent: 0,
});
}
// Send emails in batches to avoid overwhelming Resend
const batchSize = 10;
const results = {
sent: 0,
failed: 0,
errors: [] as string[],
};
for (let i = 0; i < subscribers.length; i += batchSize) {
const batch = subscribers.slice(i, i + batchSize);
// Send emails in parallel within batch
const promises = batch.map(async (subscriber) => {
try {
await sendAIFeatureLaunchEmail(subscriber.email);
results.sent++;
} catch (error) {
results.failed++;
results.errors.push(`Failed to send to ${subscriber.email}`);
console.error(`Failed to send to ${subscriber.email}:`, error);
}
});
await Promise.allSettled(promises);
// Small delay between batches to be nice to the email service
if (i + batchSize < subscribers.length) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return NextResponse.json({
success: true,
message: `Broadcast completed. Sent to ${results.sent} subscribers.`,
sent: results.sent,
failed: results.failed,
total: subscribers.length,
errors: results.errors.length > 0 ? results.errors : undefined,
});
} catch (error) {
console.error('Newsletter broadcast error:', error);
return NextResponse.json(
{
error: 'Failed to send broadcast emails. Please try again.',
},
{ status: 500 }
);
}
}
/**
* GET /api/newsletter/broadcast
* Get subscriber count and preview
* PROTECTED: Only authenticated users
*/
export async function GET(request: NextRequest) {
try {
// Check authentication using newsletter-admin cookie
const adminCookie = cookies().get('newsletter-admin')?.value;
if (adminCookie !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized. Please log in.' },
{ status: 401 }
);
}
const subscriberCount = await db.newsletterSubscription.count({
where: {
status: 'subscribed',
},
});
const recentSubscribers = await db.newsletterSubscription.findMany({
where: {
status: 'subscribed',
},
select: {
email: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
take: 5,
});
return NextResponse.json({
total: subscriberCount,
recent: recentSubscribers,
});
} catch (error) {
console.error('Error fetching subscriber info:', error);
return NextResponse.json(
{ error: 'Failed to fetch subscriber information' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { newsletterSubscribeSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { sendNewsletterWelcomeEmail } from '@/lib/email';
/**
* POST /api/newsletter/subscribe
* Subscribe to AI features newsletter
* Public endpoint - no authentication required
*/
export async function POST(request: NextRequest) {
try {
// Get client identifier for rate limiting
const clientId = getClientIdentifier(request);
// Apply rate limiting (5 per hour)
const rateLimitResult = rateLimit(clientId, RateLimits.NEWSLETTER_SUBSCRIBE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many subscription attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
'Retry-After': Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
},
}
);
}
// Parse and validate request body
const body = await request.json();
const validation = await validateRequest(newsletterSubscribeSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { email } = validation.data;
// Check if email already subscribed
const existing = await db.newsletterSubscription.findUnique({
where: { email },
});
if (existing) {
// If already subscribed, return success (idempotent)
// Don't reveal if email exists for privacy
return NextResponse.json({
success: true,
message: 'Successfully subscribed to AI features newsletter!',
alreadySubscribed: true,
});
}
// Create new subscription
await db.newsletterSubscription.create({
data: {
email,
source: 'ai-coming-soon',
status: 'subscribed',
},
});
// Send welcome email (don't block response)
sendNewsletterWelcomeEmail(email).catch((error) => {
console.error('Failed to send welcome email (non-blocking):', error);
});
return NextResponse.json({
success: true,
message: 'Successfully subscribed to AI features newsletter!',
alreadySubscribed: false,
});
} catch (error) {
console.error('Newsletter subscription error:', error);
return NextResponse.json(
{
error: 'Failed to subscribe to newsletter. Please try again.',
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
let userId: string | undefined;
// Try NextAuth session first
const session = await getServerSession(authOptions);
if (session?.user?.id) {
userId = session.user.id;
} else {
// Fallback: Check raw userId cookie (like /api/user does)
const cookieStore = await cookies();
userId = cookieStore.get('userId')?.value;
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const skip = (page - 1) * limit;
// Verify QR ownership and type
const qrCode = await db.qRCode.findUnique({
where: { id, userId: userId },
select: { id: true, contentType: true },
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Check if consistent with schema (Prisma enum mismatch fix)
// @ts-ignore - Temporary ignore until client regeneration catches up fully in all envs
if (qrCode.contentType !== 'FEEDBACK') {
return NextResponse.json({ error: 'Not a feedback QR code' }, { status: 400 });
}
// Fetch feedback entries (stored as QRScans with ipHash='feedback')
const [feedbackEntries, totalCount] = await Promise.all([
db.qRScan.findMany({
where: { qrId: id, ipHash: 'feedback' },
orderBy: { ts: 'desc' },
skip,
take: limit,
select: { id: true, userAgent: true, ts: true },
}),
db.qRScan.count({
where: { qrId: id, ipHash: 'feedback' },
}),
]);
// Parse feedback data from userAgent field
const feedbacks = feedbackEntries.map((entry) => {
const parsed = parseFeedback(entry.userAgent || '');
return {
id: entry.id,
rating: parsed.rating,
comment: parsed.comment,
date: entry.ts,
};
});
// Calculate stats
const allRatings = await db.qRScan.findMany({
where: { qrId: id, ipHash: 'feedback' },
select: { userAgent: true },
});
const ratings = allRatings.map((e) => parseFeedback(e.userAgent || '').rating).filter((r) => r > 0);
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
// Rating distribution
const distribution = {
5: ratings.filter((r) => r === 5).length,
4: ratings.filter((r) => r === 4).length,
3: ratings.filter((r) => r === 3).length,
2: ratings.filter((r) => r === 2).length,
1: ratings.filter((r) => r === 1).length,
};
return NextResponse.json({
feedbacks,
stats: {
total: totalCount,
avgRating: Math.round(avgRating * 10) / 10,
distribution,
},
pagination: {
page,
limit,
totalPages: Math.ceil(totalCount / limit),
hasMore: skip + limit < totalCount,
},
});
} catch (error) {
console.error('Error fetching feedback:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function parseFeedback(userAgent: string): { rating: number; comment: string } {
// Format: "rating:4|comment:Great service!"
const ratingMatch = userAgent.match(/rating:(\d)/);
const commentMatch = userAgent.match(/comment:(.+)/);
return {
rating: ratingMatch ? parseInt(ratingMatch[1]) : 0,
comment: commentMatch ? commentMatch[1] : '',
};
}

View File

@@ -0,0 +1,205 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
const updateQRSchema = z.object({
title: z.string().min(1).optional(),
content: z.any().optional(),
tags: z.array(z.string()).optional(),
style: z.any().optional(),
});
// GET /api/qrs/[id] - Get a single QR code
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCode = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
include: {
scans: {
orderBy: { ts: 'desc' },
take: 100,
},
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error fetching QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PATCH /api/qrs/[id] - Update a QR code
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = updateQRSchema.parse(body);
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Static QR codes cannot be edited
if (existing.type === 'STATIC' && data.content) {
return NextResponse.json(
{ error: 'Static QR codes cannot be edited' },
{ status: 400 }
);
}
// Update QR code
const updated = await db.qRCode.update({
where: { id: params.id },
data: {
...(data.title && { title: data.title }),
...(data.content && { content: data.content }),
...(data.tags && { tags: data.tags }),
...(data.style && { style: data.style }),
},
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Error updating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE /api/qrs/[id] - Delete a QR code
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Delete QR code (cascades to scans)
await db.qRCode.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function DELETE(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_DELETE_ALL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Delete all QR codes for this user
const result = await db.qRCode.deleteMany({
where: { userId },
});
return NextResponse.json({
success: true,
deletedCount: result.count,
});
} catch (error) {
console.error('Error deleting all QR codes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: {
id: true,
content: true,
contentType: true,
status: true,
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
}
if (qrCode.status === 'PAUSED') {
return NextResponse.json({ error: 'QR Code is paused' }, { status: 403 });
}
return NextResponse.json({
contentType: qrCode.contentType,
content: qrCode.content,
});
} catch (error) {
console.error('Error fetching public QR:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,234 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
// GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
_count: {
select: { scans: true },
},
scans: {
where: { isUnique: true },
select: { id: true },
},
},
orderBy: { createdAt: 'desc' },
});
// Transform the data
const transformed = qrCodes.map(qr => ({
...qr,
scans: qr._count.scans,
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
_count: undefined,
}));
return NextResponse.json(transformed);
} catch (error) {
console.error('Error fetching QR codes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// Plan limits
const PLAN_LIMITS = {
FREE: 3,
PRO: 50,
BUSINESS: 500,
};
// POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: { plan: true },
});
if (!user) {
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
}
const body = await request.json();
// Validate request body with Zod (only for non-static QRs or simplified validation)
// Note: Static QRs have complex nested content structure, so we do basic validation
if (!body.isStatic) {
const validation = await validateRequest(createQRSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
}
// Check if this is a static QR request
const isStatic = body.isStatic === true;
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
if (!isStatic) {
// Count existing dynamic QR codes
const dynamicQRCount = await db.qRCode.count({
where: {
userId,
type: 'DYNAMIC',
},
});
const userPlan = user.plan || 'FREE';
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
if (dynamicQRCount >= limit) {
return NextResponse.json(
{
error: 'Limit reached',
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
currentCount: dynamicQRCount,
limit,
plan: userPlan,
},
{ status: 403 }
);
}
}
let enrichedContent = body.content;
// For STATIC QR codes, calculate what the QR should contain
if (isStatic) {
let qrContent = '';
switch (body.contentType) {
case 'URL':
qrContent = body.content.url;
break;
case 'PHONE':
qrContent = `tel:${body.content.phone}`;
break;
case 'SMS':
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
break;
case 'VCARD':
qrContent = `BEGIN:VCARD
VERSION:3.0
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
${body.content.organization ? `ORG:${body.content.organization}` : ''}
${body.content.title ? `TITLE:${body.content.title}` : ''}
${body.content.email ? `EMAIL:${body.content.email}` : ''}
${body.content.phone ? `TEL:${body.content.phone}` : ''}
END:VCARD`;
break;
case 'GEO':
const lat = body.content.latitude || 0;
const lon = body.content.longitude || 0;
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
qrContent = `geo:${lat},${lon}${label}`;
break;
case 'TEXT':
qrContent = body.content.text;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
break;
case 'PDF':
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
break;
case 'APP':
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
break;
case 'COUPON':
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
break;
case 'FEEDBACK':
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
break;
default:
qrContent = body.content.url || 'https://example.com';
}
// Add qrContent to the content object
enrichedContent = {
...body.content,
qrContent // This is what the QR code should actually contain
};
}
// Generate slug for the QR code
const slug = generateSlug(body.title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title: body.title,
type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: body.contentType,
content: enrichedContent,
tags: body.tags || [],
style: body.style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, contentType, content, tags, style } = body;
// Generate the actual QR content based on type
let qrContent = '';
switch (contentType) {
case 'URL':
qrContent = content.url;
break;
case 'PHONE':
qrContent = `tel:${content.phone}`;
break;
case 'SMS':
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'VCARD':
qrContent = `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
N:${content.lastName || ''};${content.firstName || ''};;;
${content.organization ? `ORG:${content.organization}` : ''}
${content.title ? `TITLE:${content.title}` : ''}
${content.email ? `EMAIL:${content.email}` : ''}
${content.phone ? `TEL:${content.phone}` : ''}
END:VCARD`;
break;
case 'GEO':
const lat = content.latitude || 0;
const lon = content.longitude || 0;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
qrContent = `geo:${lat},${lon}${label}`;
break;
case 'TEXT':
qrContent = content.text;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
default:
qrContent = content.url || 'https://example.com';
}
// Store the QR content in a special field
const enrichedContent = {
...content,
qrContent // This is what the QR code should actually contain
};
// Generate slug
const slug = generateSlug(title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title,
type: 'STATIC',
contentType,
content: enrichedContent,
tags: tags || [],
style: style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating static QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CANCEL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with subscription info
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Already on free plan
if (user.plan === 'FREE') {
return NextResponse.json({ error: 'Already on free plan' }, { status: 400 });
}
// No active subscription
if (!user.stripeSubscriptionId) {
// Just update plan to FREE if somehow plan is not FREE but no subscription
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
return NextResponse.json({ success: true });
}
// Cancel the Stripe subscription
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
// Update user plan to FREE
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error canceling subscription:', error);
return NextResponse.json(
{ error: 'Failed to cancel subscription' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
try {
// Get user email from request body (since we're using simple auth, not NextAuth)
const { priceId, plan, userEmail } = await request.json();
if (!userEmail) {
return NextResponse.json({ error: 'Unauthorized - No user email provided' }, { status: 401 });
}
if (!priceId || !plan) {
return NextResponse.json(
{ error: 'Missing priceId or plan' },
{ status: 400 }
);
}
// Get user from database
const user = await db.user.findUnique({
where: { email: userEmail },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
// Get user from cookie (using userId like other routes)
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CHECKOUT);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized - Please log in' }, { status: 401 });
}
// Get plan and billing interval from request
const { plan, billingInterval = 'month' } = await request.json();
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
return NextResponse.json(
{ error: 'Invalid plan. Must be PRO or BUSINESS' },
{ status: 400 }
);
}
// Get the Stripe price ID for the plan
const planConfig = STRIPE_PLANS[plan as 'PRO' | 'BUSINESS'];
const priceId = billingInterval === 'year' ? planConfig.priceIdYearly : planConfig.priceId;
if (!priceId) {
return NextResponse.json(
{ error: 'Stripe price ID not configured for this plan' },
{ status: 500 }
);
}
// Get user from database
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_PORTAL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with Stripe customer ID
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeCustomerId: true,
email: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// If user doesn't have a Stripe customer ID, they can't access the portal
if (!user.stripeCustomerId) {
return NextResponse.json(
{ error: 'No active subscription found' },
{ status: 400 }
);
}
// Create Stripe Customer Portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error('Error creating portal session:', error);
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
/**
* Manual sync endpoint to update user subscription from Stripe
* Use this if the automatic webhook/verify failed
*/
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
}
// Get all subscriptions for this customer
const subscriptions = await stripe.subscriptions.list({
customer: user.stripeCustomerId,
status: 'active',
limit: 1,
});
if (subscriptions.data.length === 0) {
// No active subscription - set to FREE
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
return NextResponse.json({
success: true,
plan: 'FREE',
message: 'No active subscription found, set to FREE plan',
});
}
const subscription: any = subscriptions.data[0];
// Determine plan from price ID
const priceId = subscription.items.data[0]?.price?.id;
let plan = 'PRO'; // default
// Check against known price IDs
if (priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY) {
plan = 'BUSINESS';
} else if (priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY) {
plan = 'PRO';
}
// Get current_period_end
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
console.log('Syncing subscription:', {
subscriptionId: subscription.id,
priceId,
plan,
periodEndTimestamp,
currentPeriodEnd,
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
currentPeriodEnd,
});
} catch (error) {
console.error('Error syncing subscription:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
}
// Get the most recent checkout session for this customer
const checkoutSessions = await stripe.checkout.sessions.list({
customer: user.stripeCustomerId,
limit: 1,
});
if (checkoutSessions.data.length === 0) {
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
}
const checkoutSession = checkoutSessions.data[0];
// Only process if payment was successful
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
const subscriptionId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: checkoutSession.subscription.id;
// Retrieve the full subscription object
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
// Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO';
// Debug log to see the subscription structure
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
// Get current_period_end - Stripe returns it as a Unix timestamp
// Try different possible field names
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
console.log('Subscription data:', {
id: subscription.id,
periodEndTimestamp,
currentPeriodEnd,
priceId: subscription.items?.data?.[0]?.price?.id,
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
});
}
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
} catch (error) {
console.error('Error verifying session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error('Webhook signature verification failed:', error);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'subscription') {
const subscription: any = await stripe.subscriptions.retrieve(
session.subscription as string
);
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeCustomerId: session.customer as string,
},
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: (session.metadata?.plan || 'FREE') as any,
},
});
}
break;
}
case 'customer.subscription.updated': {
const subscription: any = event.data.object as Stripe.Subscription;
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
},
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { uploadFileToR2 } from '@/lib/r2';
import { env } from '@/lib/env';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
// 1. Authentication Check
const session = await getServerSession(authOptions);
let userId = session?.user?.id;
// Fallback: Check for simple-login cookie if no NextAuth session
if (!userId) {
const cookieUserId = request.cookies.get('userId')?.value;
if (cookieUserId) {
// Verify user exists
const user = await db.user.findUnique({
where: { id: cookieUserId },
select: { id: true }
});
if (user) {
userId = user.id;
}
}
}
if (!userId) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 2. Parse Form Data
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// 3. Validation
// Check file size (default 10MB)
const MAX_SIZE = parseInt(env.MAX_UPLOAD_SIZE || '10485760');
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: `File too large. Maximum size: ${MAX_SIZE / 1024 / 1024}MB` },
{ status: 400 }
);
}
// Check file type (allow images and PDFs)
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only PDF and Images are allowed.' },
{ status: 400 }
);
}
// 4. Upload to R2
const buffer = Buffer.from(await file.arrayBuffer());
const publicUrl = await uploadFileToR2(buffer, file.name, file.type);
// 5. Success
return NextResponse.json({
success: true,
url: publicUrl,
filename: file.name,
type: file.type
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Internal server error during upload' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user from database
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({
database: user,
localStorage: 'Check in browser console',
});
} catch (error) {
console.error('Debug error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function DELETE(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.ACCOUNT_DELETE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user data including Stripe information
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
stripeSubscriptionId: true,
stripeCustomerId: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Cancel Stripe subscription if user has one
if (user.stripeSubscriptionId && user.plan !== 'FREE') {
try {
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
} catch (stripeError) {
console.error('Error canceling Stripe subscription:', stripeError);
// Continue with deletion even if Stripe cancellation fails
}
}
// Delete user and all related data (cascading deletes should handle QR codes, scans, etc.)
await db.user.delete({
where: { id: userId },
});
// Clear auth cookie
cookies().delete('userId');
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting account:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { csrfProtection } from '@/lib/csrf';
import { changePasswordSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function PATCH(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_CHANGE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(changePasswordSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { currentPassword, newPassword } = validation.data;
// Get user with password
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
password: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if user has a password (OAuth users don't have passwords)
if (!user.password) {
return NextResponse.json(
{ error: 'Cannot change password for OAuth accounts' },
{ status: 400 }
);
}
// Verify current password
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: 'Current password is incorrect' },
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error changing password:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { STRIPE_PLANS } from '@/lib/stripe';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
plan: true,
stripeCurrentPeriodEnd: true,
stripePriceId: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Determine billing interval from stripePriceId
let interval: 'month' | 'year' | null = null;
if (user.stripePriceId) {
// Check if the current price ID matches any yearly price ID
const isYearly =
user.stripePriceId === STRIPE_PLANS.PRO.priceIdYearly ||
user.stripePriceId === STRIPE_PLANS.BUSINESS.priceIdYearly;
interval = isYearly ? 'year' : 'month';
}
return NextResponse.json({
plan: user.plan || 'FREE',
interval,
currentPeriodEnd: user.stripeCurrentPeriodEnd,
priceId: user.stripePriceId,
stripeCustomerId: user.stripeCustomerId,
stripeSubscriptionId: user.stripeSubscriptionId,
});
} catch (error) {
console.error('Error fetching user plan:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function PATCH(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(updateProfileSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { name } = validation.data;
// Update user name in database
const updatedUser = await db.user.update({
where: { id: userId },
data: { name: name.trim() },
select: {
id: true,
name: true,
email: true,
},
});
return NextResponse.json({
success: true,
user: updatedUser,
});
} catch (error) {
console.error('Error updating profile:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
// Force dynamic rendering (required for cookies)
export const dynamic = 'force-dynamic';
/**
* GET /api/user
* Get current user information
*/
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
} catch (error) {
console.error('Error fetching user:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with plan info
const user = await db.user.findUnique({
where: { id: userId },
select: {
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Count dynamic QR codes
const dynamicQRCount = await db.qRCode.count({
where: {
userId,
type: 'DYNAMIC',
},
});
// Count static QR codes
const staticQRCount = await db.qRCode.count({
where: {
userId,
type: 'STATIC',
},
});
// Determine limits based on plan
let dynamicLimit = 3; // FREE plan default
if (user.plan === 'PRO') {
dynamicLimit = 50;
} else if (user.plan === 'BUSINESS') {
dynamicLimit = 500;
}
return NextResponse.json({
dynamicUsed: dynamicQRCount,
dynamicLimit,
staticUsed: staticQRCount,
});
} catch (error) {
console.error('Error fetching user stats:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}