feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s

This commit is contained in:
2026-04-18 22:10:19 +02:00
parent 6ec1a3ae84
commit 3e2063e4fa
89 changed files with 4405 additions and 967 deletions

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/admin/reports — List all reports for moderation
export async function GET(request: NextRequest) {

View File

@@ -37,14 +37,57 @@ export async function GET(
// 3. Get monthly stats (views per day for last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setHours(0, 0, 0, 0);
// Note: Raw query might be needed for group by date in some DBs,
// but for now we'll just return the totals for simplicity.
const recentEvents = await prisma.analyticsEvent.findMany({
where: {
createdAt: { gte: thirtyDaysAgo },
metadata: {
path: ['businessId'],
equals: id
}
},
select: {
type: true,
createdAt: true
},
orderBy: {
createdAt: 'asc'
}
});
// 4. Process events into daily stats
const statsMap = new Map<string, { date: string, views: number, clicks: number }>();
// Initialize last 30 days with 0
for (let i = 0; i <= 30; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const displayDate = date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
statsMap.set(dateStr, { date: displayDate, views: 0, clicks: 0 });
}
recentEvents.forEach(event => {
const dateStr = event.createdAt.toISOString().split('T')[0];
if (statsMap.has(dateStr)) {
const dayStat = statsMap.get(dateStr)!;
if (event.type === 'BUSINESS_VIEW') {
dayStat.views++;
} else if (event.type === 'BUSINESS_CONTACT_CLICK') {
dayStat.clicks++;
}
}
});
// Convert Map to sorted array
const dailyStats = Array.from(statsMap.values()).reverse(); // Older to newer
return NextResponse.json({
businessId: id,
totalViews,
contactClicks,
dailyStats,
updatedAt: new Date().toISOString()
});

View File

@@ -1,17 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import prisma from '@/lib/prisma';
import { getGeoInfo } from '@/lib/geo';
export async function POST(request: NextRequest) {
console.log('>>> ANALYTICS API HIT');
try {
const body = await request.json();
console.log('>>> PAYLOAD:', JSON.stringify(body));
const { type, path, label, value, metadata } = body;
if (!type || !path) {
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';
// Basic User-Agent parsing
const isMobile = /mobile/i.test(userAgent);
const device = isMobile ? 'Mobile' : 'Desktop';
let browser = 'Other';
if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
else if (/safari/i.test(userAgent)) browser = 'Safari';
else if (/edge/i.test(userAgent)) browser = 'Edge';
let os = 'Other';
if (/windows/i.test(userAgent)) os = 'Windows';
else if (/macintosh/i.test(userAgent)) os = 'macOS';
else if (/android/i.test(userAgent)) os = 'Android';
else if (/iphone|ipad/i.test(userAgent)) os = 'iOS';
// Simple Country detection
const { country, city } = await getGeoInfo(ip, request.headers);
const event = await prisma.analyticsEvent.create({
data: {
type,
@@ -19,6 +42,13 @@ export async function POST(request: NextRequest) {
label: label || null,
value: value || null,
metadata: metadata || {},
ip,
country,
browser,
os,
device,
referrer,
userId: metadata?.userId || null,
},
});

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/blog
export async function GET() {

View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
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');
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
try {
const { value, comment } = await req.json();
if (!value || value < 1 || value > 5) {
return NextResponse.json({ error: 'Valeur de note invalide (1-5)' }, { status: 400 });
}
// 1. Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: paramId },
{ slug: paramId }
]
},
select: { id: true }
});
if (!business) {
return NextResponse.json({ error: "Les données de démonstration ne peuvent pas recevoir d'avis. Veuillez créer votre propre entreprise pour tester." }, { status: 403 });
}
const businessId = business.id;
const ownerId = (business as any).ownerId;
// 2. Security: Don't allow owner to rate themselves
if (userId === ownerId) {
return NextResponse.json({ error: "Vous ne pouvez pas noter votre propre entreprise." }, { status: 403 });
}
// 3. Fetch existing rating to check rules
const existingRating = await prisma.rating.findUnique({
where: {
userId_businessId: {
userId,
businessId
}
}
});
if (existingRating) {
// Rule: Score can only be re-evaluated UPWARDS
if (value < existingRating.value) {
return NextResponse.json({
error: `La note ne peut être réévaluée qu'à la hausse. Votre note actuelle est de ${existingRating.value} étoiles.`
}, { status: 400 });
}
// Rule: Comment is IMMUTABLE once set
// We only update the value. If the comment was empty, we can set it once.
await prisma.rating.update({
where: { id: existingRating.id },
data: {
value,
comment: existingRating.comment ? undefined : comment, // Only set if it was null
status: 'PENDING' // Reset to pending for re-moderation
}
});
} else {
// 4. Create new rating
await prisma.rating.create({
data: {
value,
comment,
userId,
businessId,
status: 'PENDING'
}
});
}
// 3. Aggregate all ratings for this business
const stats = await prisma.rating.aggregate({
where: { businessId },
_avg: { value: true },
_count: { value: true }
});
const newRating = stats._avg.value || 0;
const newRatingCount = stats._count.value || 0;
// 4. Update the business
const updatedBusiness = await prisma.business.update({
where: { id: businessId },
data: {
rating: newRating,
ratingCount: newRatingCount
}
});
return NextResponse.json({
rating: updatedBusiness.rating,
ratingCount: updatedBusiness.ratingCount,
message: 'Vote enregistré avec succès'
});
} catch (error: any) {
console.error('Error rating business:', error);
return NextResponse.json({ error: 'Erreur serveur lors du vote' }, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
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');
if (!userId) {
return NextResponse.json({ rating: null });
}
try {
// Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: paramId },
{ slug: paramId }
]
},
select: { id: true }
});
if (!business) {
return NextResponse.json({ rating: null });
}
const rating = await prisma.rating.findUnique({
where: {
userId_businessId: {
userId,
businessId: business.id
}
}
});
return NextResponse.json({
rating: rating ? rating.value : null,
status: rating ? rating.status : null,
comment: rating ? rating.comment : null
});
} catch (error: any) {
console.error('Error fetching user rating:', error);
return NextResponse.json({ error: 'Erreur lors de la récupération de votre note' }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: paramId } = await context.params;
try {
// 1. Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: paramId },
{ slug: paramId }
]
},
select: { id: true }
});
if (!business) {
// Return empty array for mock/missing businesses instead of error
return NextResponse.json([]);
}
const businessId = business.id;
// 2. Fetch all approved ratings with user info
const ratings = await prisma.rating.findMany({
where: {
businessId,
status: 'APPROVED'
},
include: {
user: {
select: {
id: true,
name: true,
avatar: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json(ratings);
} catch (error: any) {
console.error('Error fetching ratings:', error);
return NextResponse.json({ error: 'Erreur serveur lors de la récupération des avis' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
type Params = { params: Promise<{ id: string }> }
@@ -49,12 +49,48 @@ export async function PATCH(
// DELETE /api/businesses/[id]
export async function DELETE(
_request: NextRequest,
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
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({
where: { id },
select: { ownerId: true }
})
if (!business) {
return NextResponse.json({ error: 'Boutique non trouvée' }, { status: 404 })
}
// 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
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
}
// 3. Delete business
await prisma.business.delete({ where: { id } })
// 4. Update user role if they don't have other businesses
if (headerUserId && !isAdmin) {
const otherBusinesses = await prisma.business.findFirst({
where: { ownerId: headerUserId }
})
if (!otherBusinesses) {
await prisma.user.update({
where: { id: headerUserId },
data: { role: 'VISITOR' }
})
}
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('DELETE /api/businesses/[id] error:', error)

View File

@@ -1,6 +1,6 @@
// Force refresh for Next.js 16 params fix
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
import prisma from '@/lib/prisma';
export async function POST(
request: NextRequest,
@@ -13,10 +13,27 @@ export async function POST(
return NextResponse.json({ error: 'ID requis' }, { status: 400 });
}
// Attempt to increment in DB
// Note: We don't care about mock businesses here as they are static
// 1. Resolve actual Business UUID if id is a slug
const businessFound = await prisma.business.findFirst({
where: {
OR: [
{ id },
{ slug: id }
]
},
select: { id: true }
});
if (!businessFound) {
// If business not found in DB (might be mock), just return success but no update
return NextResponse.json({ success: true, message: 'Mock business, skipping view count' });
}
const businessId = businessFound.id;
// 2. Attempt to increment in DB
const business = await prisma.business.update({
where: { id },
where: { id: businessId },
data: {
viewCount: {
increment: 1,
@@ -26,8 +43,7 @@ export async function POST(
return NextResponse.json({ success: true, viewCount: business.viewCount });
} catch (error) {
// If business not found in DB (might be mock), just return success but no update
console.error('Error incrementing view count:', error);
return NextResponse.json({ success: false, error: 'Business not found or error' }, { status: 404 });
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/businesses/me — Fetch business for the current user
export async function GET(request: NextRequest) {

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import { MOCK_BUSINESSES } from '../../../lib/mockData'
import prisma from '@/lib/prisma'
// GET /api/businesses — List all businesses (Mock + DB)
export async function GET(request: NextRequest) {
@@ -9,6 +8,7 @@ export async function GET(request: NextRequest) {
const category = searchParams.get('category')
const q = searchParams.get('q')?.toLowerCase()
const featured = searchParams.get('featured')
const countryId = searchParams.get('countryId')
// 1. Fetch from Database
const where: any = {
@@ -20,6 +20,7 @@ export async function GET(request: NextRequest) {
}
if (category && category !== 'All') where.category = category
if (featured === 'true') where.isFeatured = true
if (countryId) where.countryId = countryId
if (q) {
where.OR = [
{ name: { contains: q, mode: 'insensitive' } },
@@ -35,31 +36,8 @@ export async function GET(request: NextRequest) {
orderBy: { createdAt: 'desc' },
})
// 2. Filter Mock Businesses
const filteredMocks = MOCK_BUSINESSES.filter(biz => {
// Category filter
if (category && category !== 'All' && biz.category !== category) return false;
// Featured filter
if (featured === 'true' && !biz.isFeatured) return false;
// Search query filter
if (q) {
const matches =
biz.name.toLowerCase().includes(q) ||
biz.description.toLowerCase().includes(q) ||
biz.tags.some(t => t.toLowerCase().includes(q));
if (!matches) return false;
}
return true;
});
// 3. Combine and return
// Prioritize DB entries over mocks to show user-created data first
const combined = [...dbBusinesses, ...filteredMocks];
return NextResponse.json(combined)
// 2. Return DB results
return NextResponse.json(dbBusinesses)
} catch (error) {
console.error('GET /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
@@ -84,6 +62,8 @@ export async function POST(request: NextRequest) {
slug: data.slug || null,
category: data.category || "Autre",
location: data.location || "",
countryId: data.countryId || null,
city: data.city || "",
description: data.description || "",
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
videoUrl: data.videoUrl || null,
@@ -107,7 +87,7 @@ export async function POST(request: NextRequest) {
// 1b. Calculate isActive based on mandatory fields
const isNameOk = cleanData.name && cleanData.name !== "Nouvelle Entreprise";
const isDescOk = cleanData.description && cleanData.description.length >= 20;
const isLocOk = cleanData.location && cleanData.location !== "Ma Ville";
const isLocOk = (cleanData.countryId && cleanData.city) || (cleanData.location && cleanData.location !== "Ma Ville");
const isEmailOk = !!cleanData.contactEmail;
const isPhoneOk = !!cleanData.contactPhone;

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/conversations — List all conversations for the user
export async function GET(request: NextRequest) {

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const countries = await prisma.country.findMany({
where: { isActive: true },
orderBy: { name: 'asc' }
});
return NextResponse.json(countries);
} catch (error) {
console.error('GET /api/countries error:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View File

@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
if (action === 'description') {
const prompt = `
Tu es un expert en copywriting marketing pour Afropreunariat.
Tu es un expert en copywriting marketing pour Afrohub.
Rédige une description professionnelle, attrayante et optimisée SEO pour une entreprise.
Nom de l'entreprise : ${name}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/interviews
export async function GET() {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// POST /api/messages — Send a message or start a conversation
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
export async function GET(request: NextRequest) {
try {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/offers — optionally filter by businessId
export async function GET(request: NextRequest) {
@@ -26,6 +26,44 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { businessId } = body
if (!businessId) {
return NextResponse.json({ error: 'businessId manquant' }, { status: 400 })
}
// 1. Fetch business and their plan's limit
const business = await prisma.business.findUnique({
where: { id: businessId },
include: {
_count: { select: { offers: true } }
}
})
if (!business) {
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 })
}
// Fetch the specific limit for this tier from the database
const planDetails = await prisma.pricingPlan.findUnique({
where: { tier: business.plan }
})
if (!planDetails) {
return NextResponse.json({ error: 'Configuration du forfait introuvable' }, { status: 500 })
}
// 2. Enforce limits
const currentCount = business._count.offers
const offerLimit = planDetails.offerLimit
if (currentCount >= offerLimit) {
return NextResponse.json({
error: `Limite d'offres atteinte (${offerLimit} max pour le plan ${planDetails.name}). Veuillez passer au plan supérieur pour publier plus d'offres.`
}, { status: 403 })
}
// 3. Create offer
const offer = await prisma.offer.create({
data: body,
include: { business: true },

14
app/api/plans/route.ts Normal file
View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const plans = await prisma.pricingPlan.findMany({
orderBy: { offerLimit: 'asc' }
});
return NextResponse.json(plans);
} catch (error) {
console.error('GET /api/plans error:', error);
return NextResponse.json({ error: 'Erreur lors du chargement des plans' }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
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');
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
try {
const { reply } = await req.json();
if (!reply || reply.trim() === '') {
return NextResponse.json({ error: 'La réponse ne peut pas être vide.' }, { status: 400 });
}
// 1. Fetch the rating to identify the business
const rating = await prisma.rating.findUnique({
where: { id: ratingId },
include: {
business: {
select: {
ownerId: true
}
}
}
});
if (!rating) {
return NextResponse.json({ error: 'Avis introuvable.' }, { status: 404 });
}
// 2. Security: Only the owner of the business can reply
if (rating.business.ownerId !== userId) {
return NextResponse.json({ error: 'Action non autorisée. Seul l\'entrepreneur concerné peut répondre.' }, { status: 403 });
}
// 3. Update the rating with the reply
const updatedRating = await prisma.rating.update({
where: { id: ratingId },
data: {
reply,
replyAt: new Date()
}
});
return NextResponse.json({
success: true,
reply: updatedRating.reply,
replyAt: updatedRating.replyAt
});
} catch (error: any) {
console.error('Error replying to rating:', error);
return NextResponse.json({ error: 'Erreur serveur lors de la réponse.' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
// PATCH /api/users/me — Update personal user profile
export async function PATCH(

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import prisma from '@/lib/prisma';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/users
export async function GET() {