SEO: Fix structured data validation errors, delete static sitemap, and update indexing scripts
This commit is contained in:
218
src/app/(main)/api/admin/stats/route.ts
Normal file
218
src/app/(main)/api/admin/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
288
src/app/(main)/api/analytics/summary/route.ts
Normal file
288
src/app/(main)/api/analytics/summary/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
6
src/app/(main)/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/(main)/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
89
src/app/(main)/api/auth/forgot-password/route.ts
Normal file
89
src/app/(main)/api/auth/forgot-password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/app/(main)/api/auth/google/route.ts
Normal file
165
src/app/(main)/api/auth/google/route.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/app/(main)/api/auth/reset-password/route.ts
Normal file
90
src/app/(main)/api/auth/reset-password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
src/app/(main)/api/auth/signup/route.ts
Normal file
106
src/app/(main)/api/auth/signup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
85
src/app/(main)/api/auth/simple-login/route.ts
Normal file
85
src/app/(main)/api/auth/simple-login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
src/app/(main)/api/csrf/route.ts
Normal file
14
src/app/(main)/api/csrf/route.ts
Normal 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 });
|
||||
}
|
||||
41
src/app/(main)/api/feedback/route.ts
Normal file
41
src/app/(main)/api/feedback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
103
src/app/(main)/api/leads/route.ts
Normal file
103
src/app/(main)/api/leads/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/app/(main)/api/newsletter/admin-login/route.ts
Normal file
59
src/app/(main)/api/newsletter/admin-login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
163
src/app/(main)/api/newsletter/broadcast/route.ts
Normal file
163
src/app/(main)/api/newsletter/broadcast/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/app/(main)/api/newsletter/subscribe/route.ts
Normal file
91
src/app/(main)/api/newsletter/subscribe/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
122
src/app/(main)/api/qrs/[id]/feedback/route.ts
Normal file
122
src/app/(main)/api/qrs/[id]/feedback/route.ts
Normal 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] : '',
|
||||
};
|
||||
}
|
||||
205
src/app/(main)/api/qrs/[id]/route.ts
Normal file
205
src/app/(main)/api/qrs/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/app/(main)/api/qrs/delete-all/route.ts
Normal file
60
src/app/(main)/api/qrs/delete-all/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/(main)/api/qrs/public/[slug]/route.ts
Normal file
37
src/app/(main)/api/qrs/public/[slug]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
234
src/app/(main)/api/qrs/route.ts
Normal file
234
src/app/(main)/api/qrs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
93
src/app/(main)/api/qrs/static/route.ts
Normal file
93
src/app/(main)/api/qrs/static/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/app/(main)/api/stripe/cancel-subscription/route.ts
Normal file
89
src/app/(main)/api/stripe/cancel-subscription/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/app/(main)/api/stripe/checkout/route.ts
Normal file
78
src/app/(main)/api/stripe/checkout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
115
src/app/(main)/api/stripe/create-checkout-session/route.ts
Normal file
115
src/app/(main)/api/stripe/create-checkout-session/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/(main)/api/stripe/portal/route.ts
Normal file
70
src/app/(main)/api/stripe/portal/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
src/app/(main)/api/stripe/sync-subscription/route.ts
Normal file
113
src/app/(main)/api/stripe/sync-subscription/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
97
src/app/(main)/api/stripe/verify-session/route.ts
Normal file
97
src/app/(main)/api/stripe/verify-session/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
116
src/app/(main)/api/stripe/webhook/route.ts
Normal file
116
src/app/(main)/api/stripe/webhook/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/app/(main)/api/upload/route.ts
Normal file
82
src/app/(main)/api/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/(main)/api/user/debug/route.ts
Normal file
41
src/app/(main)/api/user/debug/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
86
src/app/(main)/api/user/delete/route.ts
Normal file
86
src/app/(main)/api/user/delete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
103
src/app/(main)/api/user/password/route.ts
Normal file
103
src/app/(main)/api/user/password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/app/(main)/api/user/plan/route.ts
Normal file
59
src/app/(main)/api/user/plan/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/app/(main)/api/user/profile/route.ts
Normal file
74
src/app/(main)/api/user/profile/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/(main)/api/user/route.ts
Normal file
42
src/app/(main)/api/user/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/app/(main)/api/user/stats/route.ts
Normal file
62
src/app/(main)/api/user/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user