feat: add admin dashboard and core API routes with Docker configuration

This commit is contained in:
2026-10-29 19:46:37 +01:00
parent 2fdabe1dfd
commit 4f4856d9dc
89 changed files with 1984 additions and 1034 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../../lib/prisma'
import { isAdmin } from '@/lib/auth-utils'
// PATCH /api/admin/reports/[id] — Update report status
export async function PATCH(
@@ -7,21 +8,10 @@ export async function PATCH(
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const userId = request.headers.get('x-user-id')
const { id } = await context.params
const { status } = await request.json()
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
})
if (!user || user.role !== 'ADMIN') {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}

View File

@@ -1,22 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { isAdmin } from '@/lib/auth-utils'
// GET /api/admin/reports — List all reports for moderation
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
})
if (!user || user.role !== 'ADMIN') {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}

View File

@@ -1,17 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
import { getAuthUser } from '@/lib/auth-utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const authUser = await getAuthUser(request);
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 });
}
const { id } = await params;
if (!id) {
return NextResponse.json({ error: 'ID Business requis' }, { status: 400 });
}
// Verify ownership or ADMIN role
const business = await prisma.business.findUnique({
where: { id },
select: { id: true, ownerId: true }
});
if (!business) {
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 });
}
if (business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 });
}
// 1. Get total views from AnalyticsEvent
const totalViews = await prisma.analyticsEvent.count({
where: {

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { isIP } from 'node:net';
import prisma from '@/lib/prisma';
import { getGeoInfo } from '@/lib/geo';
@@ -7,14 +8,15 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { type, path, label, value, metadata } = body;
if (!type || !path) {
if (!type || !path || typeof type !== 'string' || typeof path !== 'string') {
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
}
// Extract detailed info from headers
const userAgent = request.headers.get('user-agent') || '';
const referrer = request.headers.get('referer') || '';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';
const userAgent = (request.headers.get('user-agent') || '').slice(0, 500);
const referrer = (request.headers.get('referer') || '').slice(0, 500);
const rawForwardedIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1';
const ip = isIP(rawForwardedIp) !== 0 ? rawForwardedIp : '127.0.0.1';
// Basic User-Agent parsing
const isMobile = /mobile/i.test(userAgent);
@@ -33,22 +35,22 @@ export async function POST(request: NextRequest) {
else if (/iphone|ipad/i.test(userAgent)) os = 'iOS';
// Simple Country detection
const { country, city } = await getGeoInfo(ip, request.headers);
const { country } = await getGeoInfo(ip, request.headers);
const event = await prisma.analyticsEvent.create({
data: {
type,
path,
label: label || null,
value: value || null,
metadata: metadata || {},
type: type.slice(0, 100),
path: path.slice(0, 500),
label: typeof label === 'string' ? label.slice(0, 200) : null,
value: typeof value === 'number' ? value : null,
metadata: metadata && typeof metadata === 'object' ? metadata : {},
ip,
country,
browser,
os,
device,
referrer,
userId: metadata?.userId || null,
userId: typeof metadata?.userId === 'string' ? metadata.userId : null,
},
});

View File

@@ -5,6 +5,15 @@ import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function POST(req: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(req);
@@ -52,13 +61,14 @@ export async function POST(req: NextRequest) {
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
const safeUserName = escapeHtml(user.name || '');
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
let html = settings?.resetPasswordTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${user.name},</p>
<p>Bonjour ${safeUserName},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
@@ -71,7 +81,7 @@ export async function POST(req: NextRequest) {
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{name}/g, safeUserName)
.replace(/{resetUrl}/g, resetUrl)
.replace(/{siteName}/g, siteName);

View File

@@ -3,6 +3,7 @@ import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
import { signUserToken, setUserSessionCookie } from '@/lib/auth-utils';
export async function POST(request: NextRequest) {
// CSRF Protection
@@ -75,11 +76,34 @@ export async function POST(request: NextRequest) {
);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
// Sign and set HttpOnly session cookie
const token = await signUserToken({
userId: user.id,
email: user.email,
role: user.role,
});
await setUserSessionCookie(token);
// Return only safe user fields (never leak password, resetToken, verificationToken)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
bio: user.bio,
location: user.location,
phone: user.phone,
countryId: user.countryId,
isSuspended: user.isSuspended,
suspensionReason: user.suspensionReason,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
return NextResponse.json(
{ message: 'Connexion réussie', user: userWithoutPassword },
{ message: 'Connexion réussie', user: safeUser },
{ status: 200 }
);

View File

@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { clearUserSessionCookie } from '@/lib/auth-utils';
export async function POST() {
try {
await clearUserSessionCookie();
return NextResponse.json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json({ error: 'Erreur lors de la déconnexion' }, { status: 500 });
}
}

View File

@@ -5,6 +5,15 @@ import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
@@ -19,7 +28,7 @@ export async function POST(request: NextRequest) {
try {
const rawBody = await request.json();
const name = rawBody.name;
const name = typeof rawBody.name === 'string' ? rawBody.name.trim() : '';
const email = rawBody.email?.toLowerCase().trim();
const password = rawBody.password;
@@ -31,9 +40,9 @@ export async function POST(request: NextRequest) {
);
}
if (password.length < 6) {
if (password.length < 8) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
{ error: 'Le mot de passe doit contenir au moins 8 caractères' },
{ status: 400 }
);
}
@@ -74,14 +83,16 @@ export async function POST(request: NextRequest) {
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const safeName = escapeHtml(name);
const safeEmail = escapeHtml(email);
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
let html = settings?.verifyEmailTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${name},</p>
<p>Bonjour ${safeName},</p>
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
@@ -89,9 +100,9 @@ export async function POST(request: NextRequest) {
</div>
`;
// Replace placeholders
// Replace placeholders with HTML-escaped values
html = html
.replace(/{name}/g, name)
.replace(/{name}/g, safeName)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);
@@ -107,7 +118,7 @@ export async function POST(request: NextRequest) {
// Send admin notification email
try {
const adminEmail = settings?.contactEmail || 'support@afrohub.com';
const adminSubject = `[Admin] Nouvelle inscription sur ${siteName} : ${name}`;
const adminSubject = `[Admin] Nouvelle inscription sur ${settings?.siteName || 'Afrohub'} : ${name}`;
const adminHtml = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #4f46e5; text-align: center;">Notification Admin - Inscription</h2>
@@ -116,11 +127,11 @@ export async function POST(request: NextRequest) {
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold; width: 35%;">Nom complet :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${name}</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${safeName}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Adresse email :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${email}">${email}</a></td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${safeEmail}">${safeEmail}</a></td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Date d'inscription :</td>
@@ -149,11 +160,25 @@ export async function POST(request: NextRequest) {
console.error('Failed to send registration email(s):', mailError);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
// Return only safe user fields (never leak password, verificationToken, resetToken)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
bio: user.bio,
location: user.location,
phone: user.phone,
countryId: user.countryId,
isSuspended: user.isSuspended,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
return NextResponse.json(
{ message: 'Compte créé avec succès', user: userWithoutPassword },
{ message: 'Compte créé avec succès', user: safeUser },
{ status: 201 }
);

View File

@@ -4,6 +4,15 @@ import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
@@ -61,7 +70,8 @@ export async function POST(request: NextRequest) {
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
const safeUserName = escapeHtml(user.name || '');
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
@@ -78,7 +88,7 @@ export async function POST(request: NextRequest) {
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{name}/g, safeUserName)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);

View File

@@ -18,10 +18,17 @@ export async function POST(request: NextRequest) {
try {
const { token, password } = await request.json();
if (!token || !password) {
if (!token || !password || typeof password !== 'string') {
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 8 caractères' },
{ status: 400 }
);
}
const user = await prisma.user.findFirst({
where: {
resetToken: token,

View File

@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
type Params = { params: Promise<{ id: string }> }
import { isAdmin } from '@/lib/auth-utils'
// GET /api/blog/[id]
export async function GET(
@@ -19,12 +18,16 @@ export async function GET(
}
}
// PATCH /api/blog/[id]
// PATCH /api/blog/[id] (Admin only)
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const { id } = await context.params
const body = await request.json()
if (body.title && body.slug === undefined) {
@@ -39,12 +42,16 @@ export async function PATCH(
}
}
// DELETE /api/blog/[id]
// DELETE /api/blog/[id] (Admin only)
export async function DELETE(
_request: NextRequest,
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const { id } = await context.params
await prisma.blogPost.delete({ where: { id } })
return NextResponse.json({ success: true })

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
import { isAdmin } from '@/lib/auth-utils'
// GET /api/blog
export async function GET() {
@@ -21,9 +22,13 @@ export async function GET() {
}
}
// POST /api/blog
// POST /api/blog (Admin only)
export async function POST(request: NextRequest) {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const body = await request.json()
if (!body.slug && body.title) {
body.slug = generateSlug(body.title)

View File

@@ -1,17 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getAuthUser } from '@/lib/auth-utils';
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: paramId } = await context.params;
const userId = req.headers.get('x-user-id');
const authUser = await getAuthUser(req);
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
const userId = authUser.id;
try {
const { value, comment } = await req.json();
@@ -27,7 +30,7 @@ export async function POST(
{ slug: paramId }
]
},
select: { id: true }
select: { id: true, ownerId: true }
});
if (!business) {
@@ -35,7 +38,7 @@ export async function POST(
}
const businessId = business.id;
const ownerId = (business as any).ownerId;
const ownerId = business.ownerId;
// 2. Security: Don't allow owner to rate themselves
if (userId === ownerId) {
@@ -85,7 +88,7 @@ export async function POST(
// 3. Aggregate all ratings for this business
const stats = await prisma.rating.aggregate({
where: { businessId },
where: { businessId, status: 'APPROVED' },
_avg: { value: true },
_count: { value: true }
});

View File

@@ -1,17 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getAuthUser } from '@/lib/auth-utils';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: paramId } = await context.params;
const userId = req.headers.get('x-user-id');
const authUser = await getAuthUser(req);
if (!userId) {
if (!authUser) {
return NextResponse.json({ rating: null });
}
const userId = authUser.id;
try {
// Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({

View File

@@ -2,8 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
type Params = { params: Promise<{ id: string }> }
// GET /api/businesses/[id]
export async function GET(
request: NextRequest,
@@ -50,11 +48,56 @@ export async function PATCH(
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const { id } = await context.params
const existing = await prisma.business.findUnique({
where: { id },
select: { ownerId: true }
})
if (!existing) {
return NextResponse.json({ error: 'Boutique non trouvée' }, { status: 404 })
}
const isAdmin = authUser.role === 'ADMIN'
const isOwner = existing.ownerId === authUser.id
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
}
const body = await request.json()
// Whitelist allowed fields for owners; sensitive fields require ADMIN role
const allowedOwnerFields = [
'name', 'slug', 'category', 'categoryId', 'suggestedCategory',
'location', 'countryId', 'city', 'description', 'logoUrl',
'coverUrl', 'coverPosition', 'coverZoom', 'videoUrl',
'contactEmail', 'contactPhone', 'websiteUrl', 'socialLinks',
'showEmail', 'showPhone', 'showSocials', 'tags',
'founderName', 'founderImageUrl', 'keyMetric', 'isActive'
]
const allowedAdminFields = [
...allowedOwnerFields,
'verified', 'plan', 'isFeatured', 'isHomeFeatured',
'isSuspended', 'suspensionReason', 'metaTitle', 'metaDescription'
]
const permittedKeys = isAdmin ? allowedAdminFields : allowedOwnerFields
const updateData: Record<string, any> = {}
for (const key of permittedKeys) {
if (body[key] !== undefined) {
updateData[key] = body[key]
}
}
const business = await prisma.business.update({
where: { id },
data: body,
data: updateData,
include: {
owner: {
select: USER_SAFE_SELECT
@@ -75,8 +118,12 @@ export async function DELETE(
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const { id } = await context.params
const headerUserId = request.headers.get('x-user-id')
// 1. Find business to check owner
const business = await prisma.business.findUnique({
@@ -89,9 +136,8 @@ export async function DELETE(
}
// 2. Auth check (Owner or Admin)
const user = headerUserId ? await prisma.user.findUnique({ where: { id: headerUserId } }) : null
const isAdmin = user?.role === 'ADMIN'
const isOwner = business.ownerId === headerUserId
const isAdmin = authUser.role === 'ADMIN'
const isOwner = business.ownerId === authUser.id
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
@@ -101,13 +147,13 @@ export async function DELETE(
await prisma.business.delete({ where: { id } })
// 4. Update user role if they don't have other businesses
if (headerUserId && !isAdmin) {
if (!isAdmin) {
const otherBusinesses = await prisma.business.findFirst({
where: { ownerId: headerUserId }
where: { ownerId: authUser.id }
})
if (!otherBusinesses) {
await prisma.user.update({
where: { id: headerUserId },
where: { id: authUser.id },
data: { role: 'VISITOR' }
})
}

View File

@@ -1,17 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// GET /api/businesses/me — Fetch business for the current user
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const business = await prisma.business.findFirst({
where: { ownerId: userId },
where: { ownerId: authUser.id },
include: { offers: true }
})

View File

@@ -91,16 +91,15 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const headerUserId = request.headers.get('x-user-id')
const { ownerId: bodyOwnerId, ...data } = body
const ownerId = bodyOwnerId || headerUserId
if (!ownerId) {
return NextResponse.json({ error: 'ownerId est requis (via body ou x-user-id header)' }, { status: 400 })
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const body = await request.json()
const { ownerId: _ignoredOwnerId, ...data } = body
const ownerId = authUser.id
// 1. Sanitize & Validate data
const cleanData: any = {
name: data.name || "",
@@ -124,11 +123,7 @@ export async function POST(request: NextRequest) {
showEmail: data.showEmail ?? true,
showPhone: data.showPhone ?? true,
showSocials: data.showSocials ?? true,
verified: data.verified ?? false,
viewCount: data.viewCount ?? 0,
rating: data.rating ?? 0,
tags: Array.isArray(data.tags) ? data.tags : [],
isFeatured: data.isFeatured ?? false,
founderName: data.founderName || null,
founderImageUrl: data.founderImageUrl || null,
keyMetric: data.keyMetric || null,
@@ -190,11 +185,13 @@ export async function POST(request: NextRequest) {
}
});
// Automatic role upgrade
await prisma.user.update({
where: { id: ownerId },
data: { role: 'ENTREPRENEUR' }
});
// Automatic role upgrade (only if VISITOR, never downgrade ADMIN)
if (authUser.role === 'VISITOR') {
await prisma.user.update({
where: { id: ownerId },
data: { role: 'ENTREPRENEUR' }
});
}
}
console.log('Business saved successfully:', business.id);

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../../lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// PATCH /api/conversations/[id]/archive — Toggle archive status for the user
export async function PATCH(
@@ -7,18 +8,18 @@ export async function PATCH(
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
const { id } = await context.params
const { archived } = await request.json()
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const updatedParticipant = await prisma.conversationParticipant.update({
where: {
userId_conversationId: {
userId: userId,
userId: authUser.id,
conversationId: id
}
},

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// GET /api/conversations/[id] — Get messages for a conversation
export async function GET(
@@ -7,13 +8,15 @@ export async function GET(
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
const { id } = await context.params
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const userId = authUser.id
// Verify user is a participant
const isParticipant = await prisma.conversationParticipant.findUnique({
where: {

View File

@@ -1,15 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// GET /api/conversations — List all conversations for the user
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const userId = authUser.id
const { searchParams } = new URL(request.url)
const businessId = searchParams.get('businessId')

View File

@@ -1,16 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getAuthUser } from '@/lib/auth-utils';
// GET /api/favorites - Get user's favorites
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id');
if (!userId) {
const authUser = await getAuthUser(request);
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
const favorites = await prisma.favorite.findMany({
where: { userId },
where: { userId: authUser.id },
include: {
business: {
include: {
@@ -32,17 +33,19 @@ export async function GET(request: NextRequest) {
// POST /api/favorites - Toggle favorite
export async function POST(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id');
const authUser = await getAuthUser(request);
const body = await request.json();
const { businessId } = body;
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
if (!businessId) {
return NextResponse.json({ error: 'businessId requis' }, { status: 400 });
}
const userId = authUser.id;
// Resolve business ID (in case slug was passed)
const business = await prisma.business.findFirst({
where: {

View File

@@ -1,9 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { GoogleGenAI } from '@google/genai'
import { getAuthUser } from '@/lib/auth-utils'
const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute
const MAX_REQUESTS_PER_WINDOW = 10
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(key: string): boolean {
const now = Date.now()
const entry = rateLimitMap.get(key)
if (!entry || now > entry.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return true
}
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
return false
}
entry.count += 1
return true
}
// POST /api/gemini — AI content generation
export async function POST(request: NextRequest) {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json(
{ error: 'Authentification requise' },
{ status: 401 }
)
}
if (!checkRateLimit(authUser.id)) {
return NextResponse.json(
{ error: 'Trop de requêtes IA. Veuillez patienter une minute.' },
{ status: 429 }
)
}
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey || apiKey === 'PLACEHOLDER_API_KEY') {
return NextResponse.json(
@@ -14,7 +48,10 @@ export async function POST(request: NextRequest) {
const ai = new GoogleGenAI({ apiKey })
const body = await request.json()
const { action, name, category, keywords } = body
const action = typeof body.action === 'string' ? body.action : ''
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 100) : ''
const category = typeof body.category === 'string' ? body.category.trim().slice(0, 100) : ''
const keywords = typeof body.keywords === 'string' ? body.keywords.trim().slice(0, 300) : ''
if (action === 'description') {
const prompt = `

View File

@@ -1,15 +1,5 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
return NextResponse.json(
{
status: 'ok',
service: 'afrohub-front',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
},
{ status: 200 }
);
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}

View File

@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
type Params = { params: Promise<{ id: string }> }
import { isAdmin } from '@/lib/auth-utils'
// GET /api/interviews/[id]
export async function GET(
@@ -19,12 +18,16 @@ export async function GET(
}
}
// PATCH /api/interviews/[id]
// PATCH /api/interviews/[id] (Admin only)
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const { id } = await context.params
const body = await request.json()
const interview = await prisma.interview.update({ where: { id }, data: body })
@@ -35,12 +38,16 @@ export async function PATCH(
}
}
// DELETE /api/interviews/[id]
// DELETE /api/interviews/[id] (Admin only)
export async function DELETE(
_request: NextRequest,
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const { id } = await context.params
await prisma.interview.delete({ where: { id } })
return NextResponse.json({ success: true })

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { isAdmin } from '@/lib/auth-utils'
// GET /api/interviews
export async function GET() {
@@ -20,9 +21,13 @@ export async function GET() {
}
}
// POST /api/interviews
// POST /api/interviews (Admin only)
export async function POST(request: NextRequest) {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const body = await request.json()
const interview = await prisma.interview.create({ data: body })
return NextResponse.json(interview, { status: 201 })

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../../lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// POST /api/messages/[id]/report — Report a message for moderation
export async function POST(
@@ -7,14 +8,16 @@ export async function POST(
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
const { id } = await context.params
const { reason } = await request.json()
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const userId = authUser.id
// Verify message exists
const message = await prisma.message.findUnique({
where: { id }

View File

@@ -1,16 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// POST /api/messages — Send a message or start a conversation
export async function POST(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
const { businessId, conversationId, content } = await request.json()
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const userId = authUser.id
if (!content) {
return NextResponse.json({ error: 'Contenu manquant' }, { status: 400 })
}

View File

@@ -1,14 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
const authUser = await getAuthUser(request)
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const userId = authUser.id
const unreadCount = await prisma.message.count({
where: {
conversation: {

View File

@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
type Params = { params: Promise<{ id: string }> }
import { getAuthUser } from '@/lib/auth-utils'
// GET /api/offers/[id]
export async function GET(
@@ -22,17 +21,43 @@ export async function GET(
}
}
// PATCH /api/offers/[id]
// PATCH /api/offers/[id] (Owner or Admin only)
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const { id } = await context.params
const existing = await prisma.offer.findUnique({
where: { id },
include: { business: { select: { ownerId: true } } }
})
if (!existing) {
return NextResponse.json({ error: 'Offre non trouvée' }, { status: 404 })
}
if (existing.business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
}
const body = await request.json()
const { title, description, price, type, imageUrl } = body
const offer = await prisma.offer.update({
where: { id },
data: body,
data: {
...(title !== undefined ? { title } : {}),
...(description !== undefined ? { description } : {}),
...(price !== undefined ? { price } : {}),
...(type !== undefined ? { type } : {}),
...(imageUrl !== undefined ? { imageUrl } : {}),
},
include: { business: true },
})
return NextResponse.json(offer)
@@ -42,13 +67,31 @@ export async function PATCH(
}
}
// DELETE /api/offers/[id]
// DELETE /api/offers/[id] (Owner or Admin only)
export async function DELETE(
_request: NextRequest,
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const { id } = await context.params
const existing = await prisma.offer.findUnique({
where: { id },
include: { business: { select: { ownerId: true } } }
})
if (!existing) {
return NextResponse.json({ error: 'Offre non trouvée' }, { status: 404 })
}
if (existing.business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
}
await prisma.offer.delete({ where: { id } })
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { getAuthUser } from '@/lib/auth-utils'
// GET /api/offers — optionally filter by businessId
export async function GET(request: NextRequest) {
@@ -22,11 +23,16 @@ export async function GET(request: NextRequest) {
}
}
// POST /api/offers
// POST /api/offers (Owner or Admin only)
export async function POST(request: NextRequest) {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const body = await request.json()
const { businessId } = body
const { businessId, title, description, price, type, imageUrl } = body
if (!businessId) {
return NextResponse.json({ error: 'businessId manquant' }, { status: 400 })
@@ -44,6 +50,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 })
}
if (business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
return NextResponse.json({ error: 'Non autorisé : vous n\'êtes pas propriétaire de cette entreprise' }, { status: 403 })
}
// Fetch the specific limit for this tier from the database
const planDetails = await prisma.pricingPlan.findUnique({
where: { tier: business.plan }
@@ -63,9 +73,16 @@ export async function POST(request: NextRequest) {
}, { status: 403 })
}
// 3. Create offer
// 3. Create offer with whitelisted fields
const offer = await prisma.offer.create({
data: body,
data: {
businessId,
title: title || '',
description: description || '',
price: price ?? '',
type: type || 'PRODUCT',
imageUrl: imageUrl || '',
},
include: { business: true },
})
return NextResponse.json(offer, { status: 201 })

View File

@@ -1,17 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getAuthUser } from '@/lib/auth-utils';
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: ratingId } = await context.params;
const userId = req.headers.get('x-user-id');
const authUser = await getAuthUser(req);
if (!userId) {
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
const userId = authUser.id;
try {
const { reply } = await req.json();

View File

@@ -1,37 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { getAuthUser, USER_SAFE_SELECT } from '@/lib/auth-utils'
// PATCH /api/users/me — Update personal user profile
export async function PATCH(
request: NextRequest,
context: { params: Promise<any> }
): Promise<Response> {
// GET /api/users/me — Get current authenticated user profile
export async function GET(request: NextRequest): Promise<Response> {
try {
const userId = request.headers.get('x-user-id')
const data = await request.json()
if (!userId) {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const user = await prisma.user.findUnique({
where: { id: authUser.id },
select: USER_SAFE_SELECT
})
if (!user) {
return NextResponse.json({ error: 'Utilisateur introuvable' }, { status: 404 })
}
return NextResponse.json(user)
} catch (error) {
console.error('GET /api/users/me error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
// PATCH /api/users/me — Update personal user profile
export async function PATCH(
request: NextRequest
): Promise<Response> {
try {
const authUser = await getAuthUser(request)
if (!authUser) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const data = await request.json()
const updatedUser = await prisma.user.update({
where: { id: userId },
where: { id: authUser.id },
data: {
name: data.name,
phone: data.phone,
bio: data.bio,
location: data.location,
},
select: {
id: true,
name: true,
email: true,
role: true,
phone: true,
bio: true,
location: true,
avatar: true
}
select: USER_SAFE_SELECT
})
return NextResponse.json(updatedUser)

View File

@@ -1,27 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getAuthUser, USER_SAFE_SELECT } from '@/lib/auth-utils';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const email = searchParams.get('email');
const authUser = await getAuthUser(request);
if (!authUser) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 });
}
if (!email) {
return NextResponse.json({ error: 'Email requis' }, { status: 400 });
const { searchParams } = new URL(request.url);
const email = searchParams.get('email')?.toLowerCase().trim() || authUser.email.toLowerCase();
// Seul l'utilisateur lui-même ou un administrateur peut consulter ce profil par email
if (authUser.role !== 'ADMIN' && authUser.email.toLowerCase() !== email) {
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 });
}
try {
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
name: true,
email: true,
role: true,
avatar: true,
phone: true,
bio: true,
location: true
}
select: USER_SAFE_SELECT
});
if (!user) {

View File

@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import prisma from '@/lib/prisma'
import { isAdmin, USER_SAFE_SELECT } from '@/lib/auth-utils'
// GET /api/users
// GET /api/users (Admin only)
export async function GET(request: NextRequest) {
try {
if (!(await isAdmin(request))) {
@@ -23,7 +24,7 @@ export async function GET(request: NextRequest) {
}
}
// POST /api/users (Admin only)
// POST /api/users (Admin only — hashes password and whitelists fields)
export async function POST(request: NextRequest) {
try {
if (!(await isAdmin(request))) {
@@ -31,12 +32,33 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
// Note: For admins, we might want to allow creating users directly,
// but we should still ensure password hashing if provided.
// For now, we just restrict it to admins to prevent the public leak.
const email = body.email?.toLowerCase().trim()
const name = body.name?.trim()
const rawPassword = body.password
if (!email || !name || !rawPassword || rawPassword.length < 8) {
return NextResponse.json(
{ error: 'Nom, email et mot de passe (min. 8 caractères) requis' },
{ status: 400 }
)
}
const hashedPassword = await bcrypt.hash(rawPassword, 10)
const allowedRole = ['VISITOR', 'ENTREPRENEUR', 'ADMIN'].includes(body.role)
? body.role
: 'VISITOR'
const user = await prisma.user.create({
data: body,
data: {
name,
email,
password: hashedPassword,
role: allowedRole,
phone: body.phone || null,
bio: body.bio || null,
location: body.location || null,
emailVerified: true,
},
select: USER_SAFE_SELECT
})
return NextResponse.json(user, { status: 201 })