feat: Souq native integration + auth simplification + CE-wide enhancements

Souq Marketplace:
- Native souq pages: browse+filters, create listing, detail+seller profile, orders+chat
- New API: reviews (POST), sellers/[id], upload (multipart), polar-checkout webhook
- Listings API enhanced: minPrice, maxPrice, sortBy, deliveryDays filters
- Seller workflow: start order, mark delivered, upload files, confirm delivery
- Wallet: 5 Polar.sh FLH tiers, production checkout, mock fallback, success banner
- Fix: order redirect /souq/history → /souq/orders

Auth System:
- Unified auth page, removed OAuth provider routing (2 files deleted)
- Deleted oauth.ts (319 lines) — simplified auth chain
- Streamlined login/register API routes

Prisma Schema (+179 lines):
- New models: Listing, Purchase, Review, Group/GroupMember
- Gamification: DailyStreak, XpTransaction, Achievement, UserLevel
- Learning: LearnCourse/Module/Enrollment/Certificate
- Notifications, Referrals, Dhikr, PremiumBenefits

Deleted legacy code:
- src/app/api/marketplace/* (3 files) — replaced by /api/souq/*
- src/app/api/files/[listingId]/route.ts — replaced by /api/souq/upload
- src/app/api/seller/[sellerId]/route.ts — replaced by /api/souq/sellers/[id]

Infrastructure:
- Dockerfile: standalone output, Prisma runtime migration
- docker-compose.yml: Polar env vars, healthcheck fix
- docker-compose.staging.yml: staging on port 4014
- .gitea/workflows/ci.yml: CI pipeline
This commit is contained in:
root
2026-06-24 07:02:03 +02:00
parent 913fa94fb9
commit cfff74e2db
81 changed files with 12132 additions and 2101 deletions
@@ -1,144 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
type Provider,
exchangeCode,
findOrCreateOAuthUser,
verifyState,
} from "@/lib/oauth";
import { signJWT } from "@/lib/auth";
const VALID_PROVIDERS = ["google", "github"];
async function handleCallback(
req: NextRequest,
provider: string
): Promise<NextResponse> {
try {
// Grab code and state from query params or POST body
const url = new URL(req.url);
let code = url.searchParams.get("code");
let stateParam = url.searchParams.get("state");
// Try reading POST body if code wasn't in query params
if (!code) {
try {
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("form")) {
const formData = await req.formData();
code = formData.get("code") as string | null;
stateParam = formData.get("state") as string | null;
}
} catch {
// not a form POST, that's fine
}
}
if (!code) {
return NextResponse.json(
{ error: "Missing authorization code" },
{ status: 400 }
);
}
if (!stateParam) {
return NextResponse.json(
{ error: "Missing state parameter" },
{ status: 400 }
);
}
// Verify state from cookie (CSRF protection)
const cookieStore = await cookies();
const storedState = cookieStore.get("oauth_state")?.value;
if (!storedState) {
return NextResponse.json(
{
error: "Missing state cookie. Please start the OAuth flow again.",
},
{ status: 400 }
);
}
const statePayload = await verifyState(stateParam);
if (!statePayload) {
return NextResponse.json(
{
error:
"Invalid or expired state. Please start the OAuth flow again.",
},
{ status: 400 }
);
}
// Verify the state and provider match
if (statePayload.provider !== provider) {
return NextResponse.json(
{ error: "State/provider mismatch" },
{ status: 400 }
);
}
// Clear the state cookie
cookieStore.set("oauth_state", "", {
httpOnly: true,
path: "/",
maxAge: 0,
});
// Build the redirect URI (must match the one used in the authorization request)
const forwardedProto = req.headers.get("x-forwarded-proto") || "https";
const rawHost = req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Exchange code for user info
const providerUser = await exchangeCode(
provider as Provider,
code,
redirectUri
);
// Find or create user in database
const user = await findOrCreateOAuthUser(
provider as Provider,
providerUser
);
// Generate JWT
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
// Redirect to frontend callback page with token
const frontendUrl = `${baseUrl}/mobile/auth/callback?token=${encodeURIComponent(token)}`;
return NextResponse.redirect(frontendUrl);
} catch (error) {
console.error(`${provider} OAuth callback error:`, error);
const message =
error instanceof Error ? error.message : "OAuth callback failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json(
{
error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
},
{ status: 400 }
);
}
return handleCallback(req, provider);
}
-68
View File
@@ -1,68 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
type Provider,
PROVIDER_CONFIGS,
generateState,
buildAuthorizationUrl,
} from "@/lib/oauth";
const VALID_PROVIDERS = ["google", "github"];
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json(
{ error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}` },
{ status: 400 }
);
}
const config = PROVIDER_CONFIGS[provider as Provider];
const clientId = process.env[config.clientIdEnv];
if (!clientId) {
return NextResponse.json(
{
error: `${provider} OAuth is not configured. Missing ${config.clientIdEnv} environment variable.`,
},
{ status: 503 }
);
}
// Build the redirect URI — the callback URL for this provider
// Use forwarded headers (Traefik/Cloudflare) or the original Host header
// to ensure the public domain is used, not the Docker container hostname.
const forwardedProto = _req.headers.get("x-forwarded-proto") || "https";
const rawHost = _req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = _req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Generate state for CSRF protection
const state = await generateState(provider as Provider);
// Store state in a cookie
const cookieStore = await cookies();
cookieStore.set("oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 10, // 10 minutes
});
// Build the authorization URL and redirect
const authUrl = buildAuthorizationUrl(
provider as Provider,
state,
redirectUri
);
return NextResponse.redirect(authUrl);
}
+38 -30
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { UMMAHID_URL } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
@@ -14,39 +13,48 @@ export async function POST(req: NextRequest) {
);
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.passwordHash) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
// Proxy to Ummah ID
const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const ummahData = await ummahRes.json();
if (!ummahRes.ok) {
return NextResponse.json(
{ error: ummahData.error || "Authentication failed" },
{ status: ummahRes.status }
);
}
const { token, user: ummahUser } = ummahData;
// Find or create local user by email
let localUser = await prisma.user.findUnique({ where: { email } });
if (!localUser) {
localUser = await prisma.user.create({
data: {
email,
name: ummahUser?.name || email?.split("@")[0] || "User",
provider: "ummahid",
providerId: ummahUser.id,
walletBalance: 5000,
},
});
}
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
id: localUser.id,
email: localUser.email,
name: localUser.name,
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
dailyMsgCount: localUser.dailyMsgCount,
},
});
} catch (error) {
+33 -85
View File
@@ -1,15 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { findReferrerByCode } from "@/lib/referral";
const REFERRER_REWARD_FLH = 200;
const REFERRED_BONUS_FLH = 1000;
import { UMMAHID_URL } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { email, name, password, referralCode } = await req.json();
const { email, name, password } = await req.json();
if (!email || !name || !password) {
return NextResponse.json(
@@ -18,96 +13,49 @@ export async function POST(req: NextRequest) {
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
// Proxy to Ummah ID
const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name, password }),
});
const ummahData = await ummahRes.json();
if (!ummahRes.ok) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
{ error: ummahData.error || "Registration failed" },
{ status: ummahRes.status }
);
}
const passwordHash = await bcrypt.hash(password, 12);
const { token, user: ummahUser } = ummahData;
// Look up referrer if a referral code was provided
let referrerId: string | null = null;
if (referralCode && typeof referralCode === "string") {
const users = await prisma.user.findMany({
select: { id: true },
// Create or find local user (find first to avoid duplicates)
let localUser = await prisma.user.findUnique({ where: { email } });
if (!localUser) {
localUser = await prisma.user.create({
data: {
email,
name: ummahUser?.name || name,
provider: "ummahid",
flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
referrerId = await findReferrerByCode(users, referralCode);
}
// Calculate starting balance
let startingBalance = 5000; // default sign-up bonus
if (referrerId) {
startingBalance += REFERRED_BONUS_FLH; // +1,000 FLH for using a referral code
}
const user = await prisma.user.create({
data: {
email,
name,
passwordHash,
provider: "email",
flhBalance: startingBalance,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// If referral code was valid, create the referral record and credit the referrer
if (referrerId) {
await prisma.$transaction([
// Create the referral record
prisma.referral.create({
data: {
referrerId,
referredId: user.id,
status: "joined",
rewardFlh: REFERRER_REWARD_FLH,
},
}),
// Credit the referrer
prisma.user.update({
where: { id: referrerId },
data: { flhBalance: { increment: REFERRER_REWARD_FLH } },
}),
// Notify the referrer
prisma.notification.create({
data: {
userId: referrerId,
type: "referral_bonus",
title: "Referral Bonus Earned!",
body: `${name} joined Falah using your referral code — you earned ${REFERRER_REWARD_FLH} FLH!`,
data: JSON.stringify({
referredUserId: user.id,
referredName: name,
rewardFlh: REFERRER_REWARD_FLH,
}),
},
}),
]);
}
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
id: localUser.id,
email: localUser.email,
name: localUser.name,
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
dailyMsgCount: localUser.dailyMsgCount,
},
referralApplied: referrerId ? true : false,
referralReward: referrerId ? REFERRED_BONUS_FLH : 0,
});
} catch (error) {
console.error("Register error:", error);
-85
View File
@@ -1,85 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ listingId: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { listingId } = await params;
const listing = await prisma.listing.findUnique({
where: { id: listingId },
select: {
id: true,
title: true,
fileType: true,
sellerId: true,
priceFlh: true,
status: true,
},
});
if (!listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}
// Check if user is the seller
if (listing.sellerId === auth.userId) {
return NextResponse.json({
file: {
id: listing.id,
title: listing.title,
fileType: listing.fileType || "unknown",
url: null,
message: "You are the seller. File delivery is handled after purchase confirmation.",
},
});
}
// Check if user has purchased this listing
const purchase = await prisma.purchase.findFirst({
where: {
listingId,
buyerId: auth.userId,
},
select: {
id: true,
amountFlh: true,
createdAt: true,
autoConfirmAt: true,
},
});
if (!purchase) {
return NextResponse.json(
{ error: "You have not purchased this listing" },
{ status: 403 }
);
}
return NextResponse.json({
file: {
id: listing.id,
title: listing.title,
fileType: listing.fileType || "unknown",
url: null, // Placeholder — file storage will be implemented in a future update
purchaseId: purchase.id,
purchasedAt: purchase.createdAt,
message: "File delivery system coming soon. Your purchase is confirmed.",
},
});
} catch (error) {
console.error("GET /api/files/[listingId] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+17
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
import { moderateContent } from "@/lib/shariah-moderation";
export async function GET(request: NextRequest) {
try {
@@ -104,6 +105,22 @@ export async function POST(request: NextRequest) {
},
});
// ── Auto-moderation for post content ──────────────────────────────
const result = moderateContent(content);
if (result.status === "approved") {
await prisma.forumPost.update({
where: { id: post.id },
data: { shariahStatus: "approved" },
});
post.shariahStatus = "approved";
} else {
await prisma.forumPost.update({
where: { id: post.id },
data: { shariahFlags: JSON.stringify(result.flags) },
});
post.shariahFlags = JSON.stringify(result.flags);
}
return NextResponse.json({ post }, { status: 201 });
} catch (error) {
console.error("POST /api/forum/posts error:", error);
+19
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
import { moderateContent } from "@/lib/shariah-moderation";
export async function GET(request: NextRequest) {
try {
@@ -150,6 +151,24 @@ export async function POST(request: NextRequest) {
},
});
// ── Auto-moderation for public threads ───────────────────────────
if (!groupId) {
const result = moderateContent(content, title);
if (result.status === "approved") {
await prisma.forumThread.update({
where: { id: thread.id },
data: { shariahStatus: "approved" },
});
thread.shariahStatus = "approved";
} else {
await prisma.forumThread.update({
where: { id: thread.id },
data: { shariahFlags: JSON.stringify(result.flags) },
});
thread.shariahFlags = JSON.stringify(result.flags);
}
}
return NextResponse.json({ thread }, { status: 201 });
} catch (error) {
console.error("POST /api/forum/threads error:", error);
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// POST /api/groups/join — join by invite code (no group ID needed)
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { inviteCode } = await request.json();
if (!inviteCode || typeof inviteCode !== "string") {
return NextResponse.json(
{ error: "Missing required field: inviteCode" },
{ status: 400 }
);
}
// Find the group by its unique invite code
const group = await prisma.group.findUnique({
where: { inviteCode },
include: {
members: {
where: { userId: auth.userId },
},
},
});
if (!group) {
return NextResponse.json(
{ error: "Invalid invite code — group not found" },
{ status: 404 }
);
}
// Check if user is already a member
if (group.members.length > 0) {
return NextResponse.json(
{ error: "You are already a member of this group" },
{ status: 409 }
);
}
// Add user as member
const membership = await prisma.groupMember.create({
data: {
groupId: group.id,
userId: auth.userId,
role: "member",
},
include: {
user: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
group: {
select: { id: true, name: true },
},
},
});
return NextResponse.json(
{ membership, group: { id: group.id, name: group.name } },
{ status: 201 }
);
} catch (error) {
console.error("POST /api/groups/join error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import fs from "fs";
// GET /api/learn/certificates/[serial] — download certificate PDF
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ serial: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { serial } = await params;
const certificate = await prisma.learnCertificate.findUnique({
where: { serialNumber: serial },
});
if (!certificate) {
return NextResponse.json(
{ error: "Certificate not found" },
{ status: 404 }
);
}
if (!certificate.pdfPath) {
return NextResponse.json(
{ error: "Certificate PDF not available" },
{ status: 404 }
);
}
const pdfBuffer = fs.readFileSync(certificate.pdfPath);
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${serial}.pdf"`,
},
});
} catch (error) {
console.error("GET /api/learn/certificates/[serial] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/learn/certificates — list authenticated user's certificates
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const certificates = await prisma.learnCertificate.findMany({
where: { userId: auth.userId },
orderBy: { issuedAt: "desc" },
});
return NextResponse.json({ certificates });
} catch (error) {
console.error("GET /api/learn/certificates error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/learn/certificates/verify/[serial] — public verification
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ serial: string }> }
) {
try {
const { serial } = await params;
const certificate = await prisma.learnCertificate.findUnique({
where: { serialNumber: serial },
include: {
course: {
select: { title: true },
},
},
});
if (!certificate) {
return NextResponse.json({ valid: false });
}
return NextResponse.json({
valid: !certificate.revoked,
serialNumber: certificate.serialNumber,
userName: certificate.userName,
courseTitle: certificate.course.title,
moduleCount: certificate.moduleCount,
score: certificate.score,
issuedAt: certificate.issuedAt.toISOString(),
revoked: certificate.revoked,
});
} catch (error) {
console.error("GET /api/learn/certificates/verify/[serial] error:", error);
return NextResponse.json({ valid: false });
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/learn/courses/[slug] — course detail with modules and user progress
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const course = await prisma.learnCourse.findUnique({
where: { slug },
include: {
modules: {
orderBy: { order: "asc" },
},
},
});
if (!course) {
return NextResponse.json(
{ error: "Course not found" },
{ status: 404 }
);
}
// Fetch user's enrollment for this course (if any)
let enrollment = await prisma.learnEnrollment.findUnique({
where: {
userId_courseId: {
userId: auth.userId,
courseId: course.id,
},
},
});
// Auto-enroll premium users (check local user record, not JWT claims)
// The JWT's licenseTier may not match the local user's isPremium flag
if (!enrollment) {
const localUser = await prisma.user.findUnique({
where: { id: auth.userId },
select: { isPremium: true },
});
if (localUser?.isPremium) {
enrollment = await prisma.learnEnrollment.create({
data: {
userId: auth.userId,
courseId: course.id,
purchaseType: "free",
},
});
}
}
// Fetch module progress for this enrollment (if enrolled)
let moduleProgressMap: Record<string, { completed: boolean; score: number | null; listened: boolean }> = {};
if (enrollment) {
const progresses = await prisma.learnModuleProgress.findMany({
where: { enrollmentId: enrollment.id },
});
for (const p of progresses) {
moduleProgressMap[p.moduleId] = {
completed: p.completed,
score: p.score,
listened: p.listened,
};
}
}
// Build response
const modules = course.modules.map((mod) => ({
id: mod.id,
order: mod.order,
title: mod.title,
slug: mod.slug,
keyTakeaway: mod.keyTakeaway,
duration: mod.duration,
content: mod.content,
quizData: mod.quizData ? JSON.parse(mod.quizData) : null,
progress: moduleProgressMap[mod.id] ?? null,
}));
return NextResponse.json({
course: {
id: course.id,
slug: course.slug,
title: course.title,
author: course.author,
description: course.description,
imageUrl: course.imageUrl,
moduleCount: course.moduleCount,
totalMinutes: course.totalMinutes,
difficulty: course.difficulty,
giteaPath: course.giteaPath,
},
modules,
enrollment: enrollment
? {
id: enrollment.id,
purchaseType: enrollment.purchaseType,
completed: enrollment.completed,
progress: enrollment.progress,
}
: null,
});
} catch (error) {
console.error("GET /api/learn/courses/[slug] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
const userId = auth?.userId;
const courses = await prisma.learnCourse.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
// If authenticated, fetch the user's enrollments for the returned courses
let enrollmentsMap = new Map<string, { progress: number; completed: boolean }>();
if (userId && courses.length > 0) {
const enrollments = await prisma.learnEnrollment.findMany({
where: {
userId,
courseId: { in: courses.map((c) => c.id) },
},
select: { courseId: true, progress: true, completed: true },
});
for (const e of enrollments) {
enrollmentsMap.set(e.courseId, { progress: e.progress, completed: e.completed });
}
}
const result = courses.map((course) => {
const enrollment = enrollmentsMap.get(course.id);
return {
id: course.id,
slug: course.slug,
title: course.title,
author: course.author,
description: course.description,
imageUrl: course.imageUrl,
moduleCount: course.moduleCount,
totalMinutes: course.totalMinutes,
difficulty: course.difficulty,
priceFlh: course.priceFlh,
priceUsd: course.priceUsd,
subscriptionOnly: course.subscriptionOnly,
enrolled: userId ? !!enrollment : null,
...(enrollment ? { enrollment: { progress: enrollment.progress, completed: enrollment.completed } } : {}),
};
});
return NextResponse.json({ courses: result });
} catch (error) {
console.error("GET /api/learn/courses error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+215
View File
@@ -0,0 +1,215 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import {
generateCertificatePdf,
generateSerialNumber,
buildVerifyUrl,
} from "@/lib/learn";
export async function POST(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const { moduleId, completed, score, listened } = body;
if (!moduleId || typeof completed !== "boolean") {
return NextResponse.json(
{ error: "moduleId (string) and completed (boolean) are required" },
{ status: 400 }
);
}
// 1. Find the module with its course relation
const module = await prisma.learnModule.findUnique({
where: { id: moduleId },
include: { course: true },
});
if (!module) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
if (!module.course.published) {
return NextResponse.json(
{ error: "Course is not published" },
{ status: 403 }
);
}
// 2. Find the enrollment for this user + course
let enrollment = await prisma.learnEnrollment.findUnique({
where: {
userId_courseId: {
userId: jwt.userId,
courseId: module.courseId,
},
},
});
if (!enrollment) {
return NextResponse.json(
{ error: "You are not enrolled in this course" },
{ status: 403 }
);
}
// 3. Upsert LearnModuleProgress
await prisma.learnModuleProgress.upsert({
where: {
enrollmentId_moduleId: {
enrollmentId: enrollment.id,
moduleId: module.id,
},
},
update: {
completed,
...(score !== undefined ? { score } : {}),
...(listened !== undefined ? { listened } : {}),
...(completed ? { completedAt: new Date() } : {}),
},
create: {
enrollmentId: enrollment.id,
moduleId: module.id,
completed,
...(score !== undefined ? { score } : {}),
...(listened !== undefined ? { listened } : {}),
...(completed ? { completedAt: new Date() } : {}),
},
});
// 4. Count completed modules vs total to compute progress
const totalModules = await prisma.learnModule.count({
where: { courseId: module.courseId },
});
const completedProgressRecords = await prisma.learnModuleProgress.count({
where: {
enrollmentId: enrollment.id,
completed: true,
},
});
const progress =
totalModules > 0 ? completedProgressRecords / totalModules : 0;
// 5. If completing (progress >= 1), handle certificate + enrollment atomically
let certificate: {
serialNumber: string;
verifyUrl: string;
pdfPath: string;
} | undefined;
if (progress >= 1 && !enrollment.completed) {
const now = new Date();
// Fetch user info for certificate (before transaction)
const user = await prisma.user.findUnique({
where: { id: jwt.userId },
select: { name: true },
});
const course = module.course;
const serialNumber = generateSerialNumber(
jwt.userId,
course.id,
course.slug,
now
);
const certificateData = {
serialNumber,
userName: user?.name ?? "Student",
courseTitle: course.title,
courseAuthor: course.author,
moduleCount: totalModules,
score: score ?? undefined,
issuedAt: now,
verifyUrl: buildVerifyUrl(serialNumber),
};
// Generate PDF before transaction — if it fails, transaction never runs
const pdfPath = await generateCertificatePdf(certificateData);
// All DB mutations inside a single Prisma transaction for atomicity
const result = await prisma.$transaction(async (tx) => {
// Race condition guard: check no existing certificate for this user + course
const existingCert = await tx.learnCertificate.findFirst({
where: { userId: jwt.userId, courseId: course.id },
});
if (existingCert) {
// Already issued — still update enrollment but don't create another
const updatedEnrollment = await tx.learnEnrollment.update({
where: { id: enrollment.id },
data: {
progress,
completed: true,
completedAt: now,
},
});
return { enrollment: updatedEnrollment, certificate: null };
}
// Update enrollment (progress + completion) and create certificate atomically
const updatedEnrollment = await tx.learnEnrollment.update({
where: { id: enrollment.id },
data: {
progress,
completed: true,
completedAt: now,
},
});
await tx.learnCertificate.create({
data: {
serialNumber,
userId: jwt.userId,
userName: user?.name ?? "Student",
courseId: course.id,
moduleCount: totalModules,
score: score ?? null,
pdfPath,
issuedAt: now,
},
});
return {
enrollment: updatedEnrollment,
certificate: {
serialNumber,
verifyUrl: certificateData.verifyUrl,
pdfPath,
},
};
});
enrollment = result.enrollment;
if (result.certificate) {
certificate = result.certificate;
}
} else {
// No completion — just update progress outside transaction
enrollment = await prisma.learnEnrollment.update({
where: { id: enrollment.id },
data: { progress },
});
}
return NextResponse.json({
progress,
completed: enrollment.completed,
...(certificate ? { certificate } : {}),
});
} catch (error) {
console.error("Learn progress error:", error);
return NextResponse.json(
{ error: "Failed to update progress" },
{ status: 500 }
);
}
}
+530
View File
@@ -0,0 +1,530 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// ─── Simple YAML parser for the subset of YAML we expect ───
/** Strip surrounding quotes from a string */
function stripQuotes(s: string): string {
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
return s.slice(1, -1);
}
return s;
}
/** Parse a scalar value (string, number, boolean, null) */
function parseScalar(raw: string): string | number | boolean | null {
const trimmed = raw.trim();
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
// Try number
const num = Number(trimmed);
if (!isNaN(num) && trimmed !== "") return num;
return stripQuotes(trimmed);
}
interface ParseResult {
value: any;
nextLine: number;
}
/**
* Parse a block of YAML lines starting at `startLine`.
* Detects whether the block is a list (lines start with `- ` at base indent)
* or an object (key: value pairs).
*/
function parseBlock(lines: string[], startLine: number, baseIndent: number): ParseResult {
// Skip empty lines
let i = startLine;
while (i < lines.length && lines[i].trim() === "") i++;
if (i >= lines.length) return { value: null, nextLine: i };
const firstNonEmpty = lines[i];
// Detect leading whitespace of the first content line
const firstIndent = firstNonEmpty.search(/\S/);
// Check if this is a list (first non-empty line starts with "- " at base indent)
if (firstNonEmpty.trimStart().startsWith("- ")) {
return parseList(lines, i, firstIndent);
}
// Otherwise treat as an object (key: value)
return parseMapping(lines, i, firstIndent);
}
/** Parse a YAML sequence (list) */
function parseList(lines: string[], startLine: number, indent: number): ParseResult {
const items: any[] = [];
let i = startLine;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed === "") {
i++;
continue;
}
// Check current indent
const currentIndent = lines[i].search(/\S/);
if (currentIndent < indent) break; // outdented, we're done
if (currentIndent !== indent) {
// If it's indented more, it might be part of the previous item's value
// For our use case, this shouldn't happen at the top level of a list
i++;
continue;
}
if (!trimmed.startsWith("- ")) {
// Not a list item anymore
break;
}
const rest = trimmed.slice(2).trim();
// Check if the item has inline value or is a sub-block
if (rest === "") {
// Empty list item - could have sub-items indented below
// Peek ahead for sub-block
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > indent) {
const sub = parseBlock(lines, i + 1, nextIndent);
items.push(sub.value);
i = sub.nextLine;
continue;
}
}
items.push(null);
i++;
} else if (rest.includes(":") && !rest.startsWith('"') && !rest.startsWith("'")) {
// The item might be an inline mapping start: "key: value" or just "key:"
// Actually, for our format, modules have: `- slug: "value"` which is inline
// Let's check if there are sub-properties on subsequent lines
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > indent) {
// This list item has sub-properties; parse the rest inline as a mapping
// Treat this line as the first key:value of the sub-mapping
const subResult = parseMappingFromLine(lines, i, indent);
items.push(subResult.value);
i = subResult.nextLine;
continue;
}
}
// No sub-properties, treat the whole line as a mapping key:value
const colonIdx = rest.indexOf(":");
const key = stripQuotes(rest.slice(0, colonIdx).trim());
const valStr = rest.slice(colonIdx + 1).trim();
const val = valStr ? parseScalar(valStr) : null;
const obj: Record<string, any> = {};
obj[key] = val;
items.push(obj);
i++;
} else {
// Simple scalar list item
items.push(parseScalar(rest));
i++;
}
}
return { value: items, nextLine: i };
}
/**
* Parse a mapping (key: value) starting from a specific line.
* The mapping ends when indentation returns to or above baseIndent.
*/
function parseMapping(lines: string[], startLine: number, baseIndent: number): ParseResult {
const result: Record<string, any> = {};
let i = startLine;
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (trimmedLine === "") {
i++;
continue;
}
const currentIndent = line.search(/\S/);
if (currentIndent < baseIndent) break; // outdented
if (currentIndent !== baseIndent) {
// This shouldn't happen at the mapping level, skip
i++;
continue;
}
// Must be a key: value pair
if (!trimmedLine.includes(":")) {
i++;
continue;
}
const colonIdx = trimmedLine.indexOf(":");
const key = stripQuotes(trimmedLine.slice(0, colonIdx).trim());
let rest = trimmedLine.slice(colonIdx + 1).trim();
if (rest === "") {
// Value might be a nested block on subsequent lines
// Check next line
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > currentIndent) {
const subResult = parseBlock(lines, i + 1, nextIndent);
result[key] = subResult.value;
i = subResult.nextLine;
continue;
}
}
// No value or empty value
result[key] = null;
i++;
} else {
// Inline scalar value
result[key] = parseScalar(rest);
i++;
}
}
return { value: result, nextLine: i };
}
/**
* Special case: when a list item like `- slug: "value"` has sub-properties
* on subsequent indented lines, parse it as a mapping starting with the current
* line's content.
*/
function parseMappingFromLine(lines: string[], startLine: number, parentIndent: number): ParseResult {
const result: Record<string, any> = {};
let i = startLine;
// Parse the first line which already has content like "- slug: value"
const firstLine = lines[i];
const afterDash = firstLine.trim().slice(2).trim();
const colonIdx = afterDash.indexOf(":");
const firstKey = stripQuotes(afterDash.slice(0, colonIdx).trim());
const firstValStr = afterDash.slice(colonIdx + 1).trim();
result[firstKey] = firstValStr ? parseScalar(firstValStr) : null;
i++;
// Now parse subsequent lines at a higher indent
const subIndent = parentIndent + 2; // typical YAML indent
while (i < lines.length) {
const line = lines[i];
const trimmedLine = line.trim();
if (trimmedLine === "") {
i++;
continue;
}
const currentIndent = line.search(/\S/);
if (currentIndent <= parentIndent) break; // back to parent level
if (!trimmedLine.includes(":")) {
i++;
continue;
}
const ci = trimmedLine.indexOf(":");
const key = stripQuotes(trimmedLine.slice(0, ci).trim());
const rest = trimmedLine.slice(ci + 1).trim();
if (rest === "") {
// Could have nested value below
if (i + 1 < lines.length) {
const nextIndent = lines[i + 1].search(/\S/);
if (nextIndent > currentIndent) {
const subResult = parseBlock(lines, i + 1, nextIndent);
result[key] = subResult.value;
i = subResult.nextLine;
continue;
}
}
result[key] = null;
i++;
} else {
result[key] = parseScalar(rest);
i++;
}
}
return { value: result, nextLine: i };
}
// ─── Gitea API helpers ───
const GITEA_BASE = "https://git.falahos.my/api/v1/repos/maifors/courses/contents";
function getGiteaToken(): string {
const token = process.env.GITEA_TOKEN;
if (!token) {
throw new Error("GITEA_TOKEN environment variable is not set");
}
return token;
}
interface GiteaFileResponse {
name: string;
path: string;
content: string;
encoding: string;
}
async function fetchGiteaFile(filePath: string): Promise<string> {
const url = `${GITEA_BASE}/${filePath}`;
const token = getGiteaToken();
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(
`Gitea API error: ${res.status} ${res.statusText} for ${filePath}`
);
}
const data: GiteaFileResponse = await res.json();
if (data.encoding !== "base64" || !data.content) {
throw new Error(`Unexpected Gitea response format for ${filePath}`);
}
return Buffer.from(data.content, "base64").toString("utf-8");
}
// ─── Course sync logic ───
interface CourseYaml {
title?: string;
author?: string;
description?: string;
imageUrl?: string;
difficulty?: string;
priceFlh?: number;
priceUsd?: number;
subscriptionOnly?: boolean;
published?: boolean;
modules?: Array<{
slug?: string;
title?: string;
keyTakeaway?: string;
duration?: number;
}>;
}
interface QuizQuestion {
question?: string;
options?: string[];
correctIndex?: number;
}
interface QuizYaml {
questions?: QuizQuestion[];
}
async function syncCourse(slug: string): Promise<void> {
// Fetch course.yaml
const courseYamlText = await fetchGiteaFile(`courses/${slug}/course.yaml`);
const parsed = parseBlock(courseYamlText.split("\n"), 0, 0);
const courseData = parsed.value as CourseYaml;
if (!courseData || typeof courseData !== "object") {
throw new Error(`Invalid course.yaml for slug: ${slug}`);
}
const giteaPath = `courses/${slug}`;
// Upsert the course
const course = await prisma.learnCourse.upsert({
where: { slug },
create: {
slug,
title: courseData.title || slug,
author: courseData.author || "Unknown",
description: courseData.description || "",
imageUrl: courseData.imageUrl || null,
difficulty: courseData.difficulty || "beginner",
giteaPath,
priceFlh: courseData.priceFlh ?? null,
priceUsd: courseData.priceUsd ?? null,
subscriptionOnly: courseData.subscriptionOnly ?? false,
published: courseData.published ?? false,
moduleCount: 0,
totalMinutes: 0,
},
update: {
title: courseData.title || slug,
author: courseData.author || "Unknown",
description: courseData.description || "",
imageUrl: courseData.imageUrl || null,
difficulty: courseData.difficulty || "beginner",
giteaPath,
priceFlh: courseData.priceFlh ?? null,
priceUsd: courseData.priceUsd ?? null,
subscriptionOnly: courseData.subscriptionOnly ?? false,
published: courseData.published ?? false,
},
});
// Sync modules
let totalMinutes = 0;
const modules = courseData.modules || [];
for (let order = 0; order < modules.length; order++) {
const modData = modules[order];
if (!modData || !modData.slug) continue;
const modSlug = modData.slug;
const modTitle = modData.title || modSlug;
// Fetch lesson.md
let lessonContent: string | null = null;
try {
lessonContent = await fetchGiteaFile(
`courses/${slug}/modules/${modSlug}/lesson.md`
);
} catch {
// lesson.md might not exist yet
lessonContent = null;
}
// Fetch quiz.yaml
let quizData: string | null = null;
try {
const quizYamlText = await fetchGiteaFile(
`courses/${slug}/modules/${modSlug}/quiz.yaml`
);
const quizParsed = parseBlock(quizYamlText.split("\n"), 0, 0);
const quiz = quizParsed.value as QuizYaml;
if (quiz && Array.isArray(quiz.questions)) {
quizData = JSON.stringify(quiz.questions);
}
} catch {
// quiz.yaml might not exist yet
quizData = null;
}
const duration = modData.duration || 5;
totalMinutes += duration;
await prisma.learnModule.upsert({
where: {
courseId_slug: {
courseId: course.id,
slug: modSlug,
},
},
create: {
courseId: course.id,
order,
title: modTitle,
slug: modSlug,
keyTakeaway: modData.keyTakeaway || null,
duration,
content: lessonContent,
quizData,
},
update: {
order,
title: modTitle,
keyTakeaway: modData.keyTakeaway || null,
duration,
content: lessonContent ?? undefined,
quizData: quizData ?? undefined,
},
});
}
// Update course module count and total minutes
await prisma.learnCourse.update({
where: { id: course.id },
data: {
moduleCount: modules.length,
totalMinutes,
},
});
}
// ─── Route handlers ───
/**
* GET /api/learn/sync — health check
*/
export async function GET() {
return NextResponse.json({ status: "ok", lastSync: null });
}
/**
* POST /api/learn/sync — Gitea webhook receiver
*
* Accepts a secret token via ?token= query param or Authorization: Bearer <token> header.
* Compares against SYNC_SECRET env var.
* On success, fetches courses from Gitea and syncs to local DB.
*/
export async function POST(req: NextRequest) {
try {
// 1. Validate secret token
const url = new URL(req.url);
const token =
url.searchParams.get("token") ||
req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
if (!token || token !== process.env.SYNC_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. Fetch course index from Gitea
const indexYamlText = await fetchGiteaFile("courses/course-index.yaml");
const indexParsed = parseBlock(indexYamlText.split("\n"), 0, 0);
const indexValue = indexParsed.value;
// The index should be a simple list of slugs
let courseSlugs: string[] = [];
if (Array.isArray(indexValue)) {
courseSlugs = indexValue
.map((item: any) => (typeof item === "string" ? item.trim() : null))
.filter((s: string | null): s is string => !!s);
} else if (typeof indexValue === "object" && indexValue !== null) {
// Fallback: object keys as slugs
courseSlugs = Object.keys(indexValue);
}
if (courseSlugs.length === 0) {
return NextResponse.json(
{ error: "No courses found in course-index.yaml" },
{ status: 400 }
);
}
// 3. Sync each course
const syncedCourses: string[] = [];
for (const slug of courseSlugs) {
try {
await syncCourse(slug);
syncedCourses.push(slug);
} catch (err) {
console.error(`Failed to sync course "${slug}":`, err);
// Continue with other courses
}
}
return NextResponse.json({
synced: syncedCourses.length,
courses: syncedCourses,
});
} catch (error) {
console.error("Gitea sync error:", error);
return NextResponse.json(
{ error: "Sync failed: " + (error instanceof Error ? error.message : "Unknown error") },
{ status: 500 }
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { existsSync } from "fs";
import { join } from "path";
/**
* GET /api/learn/tts?moduleId=xxx
* Returns module text content and whether audio is cached on disk.
* The client uses Web Speech API as the baseline — no server-side TTS yet.
*/
export async function GET(request: NextRequest) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const moduleId = searchParams.get("moduleId");
if (!moduleId) {
return NextResponse.json(
{ error: "moduleId query parameter is required" },
{ status: 400 }
);
}
const module = await prisma.learnModule.findUnique({
where: { id: moduleId },
select: { id: true, content: true, audioPath: true },
});
if (!module) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
let hasAudio = false;
if (module.audioPath) {
const fullPath = join(process.cwd(), "public", module.audioPath);
hasAudio = existsSync(fullPath);
}
return NextResponse.json({
moduleId: module.id,
text: module.content ?? "",
hasAudio,
});
} catch (error) {
console.error("GET /api/learn/tts error:", error);
return NextResponse.json(
{ error: "Failed to retrieve TTS data" },
{ status: 500 }
);
}
}
/**
* POST /api/learn/tts
* Body: { moduleId }
* 1) Verifies auth, 2) Finds the LearnModule, 3) Checks if audio is cached on disk,
* 4) Returns text content so the client can use Web Speech API (SpeechSynthesis).
* Server-side TTS generation can be added later.
*/
export async function POST(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const { moduleId } = body;
if (!moduleId) {
return NextResponse.json(
{ error: "moduleId is required" },
{ status: 400 }
);
}
const module = await prisma.learnModule.findUnique({
where: { id: moduleId },
select: { id: true, content: true, audioPath: true },
});
if (!module) {
return NextResponse.json({ error: "Module not found" }, { status: 404 });
}
const text = module.content ?? "";
// Check if audio is already cached on disk
if (module.audioPath) {
const fullPath = join(process.cwd(), "public", module.audioPath);
if (existsSync(fullPath)) {
return NextResponse.json({
audioUrl: module.audioPath.startsWith("/")
? module.audioPath
: `/audio/${module.audioPath}`,
cached: true,
text,
});
}
}
// No cached audio — return text for client-side Web Speech API
// If we don't have an audioPath yet, set a pending marker so future
// server-side TTS knows this module is queued.
if (!module.audioPath) {
await prisma.learnModule.update({
where: { id: module.id },
data: { audioPath: "__pending__" },
});
}
return NextResponse.json({
text,
moduleId: module.id,
ttsUrl: null,
cached: false,
message:
"Audio not yet generated. Use Web Speech API (SpeechSynthesis) on the client to speak this text.",
});
} catch (error) {
console.error("POST /api/learn/tts error:", error);
return NextResponse.json(
{ error: "Failed to process TTS request" },
{ status: 500 }
);
}
}
-98
View File
@@ -1,98 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (!user.isPro) {
return NextResponse.json(
{ error: "Only Pro members can feature listings. Upgrade to Pro to access this feature." },
{ status: 403 }
);
}
const { listingId } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Verify listing exists and belongs to user
const listing = await prisma.listing.findUnique({
where: { id: listingId },
});
if (!listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}
if (listing.sellerId !== user.id) {
return NextResponse.json(
{ error: "You can only feature your own listings" },
{ status: 403 }
);
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "Cannot feature a sold or inactive listing" },
{ status: 400 }
);
}
if (listing.featured) {
return NextResponse.json(
{ error: "Listing is already featured" },
{ status: 400 }
);
}
// Check FLH balance
const FEATURE_FEE = 100;
if (user.flhBalance < FEATURE_FEE) {
return NextResponse.json(
{ error: `Insufficient FLH balance. Featuring costs ${FEATURE_FEE} FLH. Your balance: ${user.flhBalance} FLH.` },
{ status: 400 }
);
}
// Deduct fee and set featured
const [updatedListing] = await prisma.$transaction([
prisma.listing.update({
where: { id: listingId },
data: {
featured: true,
featuredUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
},
}),
prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: FEATURE_FEE } },
}),
]);
return NextResponse.json({
listing: updatedListing,
message: `Listing featured for 30 days. ${FEATURE_FEE} FLH deducted.`,
});
} catch (error) {
console.error("POST /api/marketplace/feature error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
-131
View File
@@ -1,131 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, MARKETPLACE_LIMITS } from "@/lib/tiers";
const CATEGORIES = ["E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const where: any = { status: "active" };
if (category && CATEGORIES.includes(category)) {
where.category = category;
}
if (search) {
where.OR = [
{ title: { contains: search } },
{ description: { contains: search } },
];
}
const listings = await prisma.listing.findMany({
where,
include: {
seller: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
orderBy: [
{ featured: "desc" },
{ createdAt: "desc" },
],
});
return NextResponse.json({ listings });
} catch (error) {
console.error("GET /api/marketplace/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = MARKETPLACE_LIMITS[tier];
if (!limits.canSell) {
return NextResponse.json(
{ error: "Your tier does not support selling. Upgrade to Premium or Pro to create listings." },
{ status: 403 }
);
}
const { title, description, category, priceFlh } = await request.json();
if (!title || !description || !category || priceFlh == null) {
return NextResponse.json(
{ error: "Missing required fields: title, description, category, priceFlh" },
{ status: 400 }
);
}
if (!CATEGORIES.includes(category)) {
return NextResponse.json(
{ error: `Invalid category. Must be one of: ${CATEGORIES.join(", ")}` },
{ status: 400 }
);
}
if (typeof priceFlh !== "number" || priceFlh < 1 || !Number.isInteger(priceFlh)) {
return NextResponse.json(
{ error: "priceFlh must be a positive integer" },
{ status: 400 }
);
}
// Check listing count limit
const activeListingsCount = await prisma.listing.count({
where: { sellerId: user.id, status: "active" },
});
if (activeListingsCount >= limits.maxListings) {
return NextResponse.json(
{ error: `You have reached the maximum of ${limits.maxListings} active listings for your tier.` },
{ status: 403 }
);
}
const listing = await prisma.listing.create({
data: {
title,
description,
category,
priceFlh,
sellerId: user.id,
status: "active",
},
include: {
seller: {
select: { id: true, name: true, isPremium: true, isPro: true },
},
},
});
return NextResponse.json({ listing }, { status: 201 });
} catch (error) {
console.error("POST /api/marketplace/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
-136
View File
@@ -1,136 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier } from "@/lib/tiers";
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const buyer = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!buyer) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { listingId } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Get listing with seller info
const listing = await prisma.listing.findUnique({
where: { id: listingId },
include: {
seller: {
select: { id: true, isPremium: true, isPro: true },
},
},
});
if (!listing) {
return NextResponse.json({ error: "Listing not found" }, { status: 404 });
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "This listing is no longer available" },
{ status: 400 }
);
}
if (listing.sellerId === buyer.id) {
return NextResponse.json(
{ error: "You cannot purchase your own listing" },
{ status: 400 }
);
}
// Check buyer balance
if (buyer.flhBalance < listing.priceFlh) {
return NextResponse.json(
{
error: `Insufficient FLH balance. You need ${listing.priceFlh} FLH but only have ${buyer.flhBalance} FLH.`,
},
{ status: 400 }
);
}
// Calculate platform fee
// Pro sellers pay 0% platform fee, free/premium pay 1.5%
const sellerTier = getUserTier(listing.seller.isPremium, listing.seller.isPro);
const platformFeeRate = sellerTier === "pro" ? 0 : 0.015;
const platformFee = Math.round(listing.priceFlh * platformFeeRate);
// FLH Earning Multiplier: Premium/Pro sellers earn 2x their payout
const sellerMultiplier = listing.seller.isPremium || listing.seller.isPro ? 2 : 1;
const basePayout = listing.priceFlh - platformFee;
const sellerPayout = basePayout * sellerMultiplier;
// Auto-confirm in 7 days
const autoConfirmAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// Execute purchase in transaction
const [purchase] = await prisma.$transaction([
prisma.purchase.create({
data: {
listingId: listing.id,
buyerId: buyer.id,
sellerId: listing.sellerId,
amountFlh: listing.priceFlh,
platformFee,
sellerPayout,
autoConfirmAt,
},
include: {
listing: {
select: {
id: true,
title: true,
priceFlh: true,
},
},
},
}),
// Deduct from buyer
prisma.user.update({
where: { id: buyer.id },
data: { flhBalance: { decrement: listing.priceFlh } },
}),
// Add payout to seller (with multiplier applied)
prisma.user.update({
where: { id: listing.sellerId },
data: { flhBalance: { increment: sellerPayout } },
}),
// Mark listing as sold
prisma.listing.update({
where: { id: listing.id },
data: { status: "sold" },
}),
]);
const multiplierNote = sellerMultiplier > 1
? ` Seller earned ${sellerMultiplier}x FLH multiplier (Premium/Pro bonus).`
: "";
return NextResponse.json(
{
purchase,
message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.${multiplierNote}`,
},
{ status: 201 }
);
} catch (error) {
console.error("POST /api/marketplace/purchase error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
-54
View File
@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sellerId: string }> }
) {
try {
const { sellerId } = await params;
const seller = await prisma.user.findUnique({
where: { id: sellerId },
select: {
id: true,
name: true,
isPremium: true,
isPro: true,
createdAt: true,
},
});
if (!seller) {
return NextResponse.json({ error: "Seller not found" }, { status: 404 });
}
const listings = await prisma.listing.findMany({
where: {
sellerId,
status: "active",
},
select: {
id: true,
title: true,
description: true,
category: true,
priceFlh: true,
featured: true,
createdAt: true,
},
orderBy: [
{ featured: "desc" },
{ createdAt: "desc" },
],
});
return NextResponse.json({ seller, listings });
} catch (error) {
console.error("GET /api/seller/[sellerId] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
const CATEGORIES = [
{ id: "graphics-design", name: "Graphics & Design", icon: "🎨", color: "bg-pink-900/30 text-pink-400", subcategories: ["Logo & Brand Identity", "Art & Illustration", "Web & App Design", "Product & Gaming", "Print Design", "Visual Design", "Marketing Design", "Packaging & Covers", "Architecture & Building Design", "Fashion & Merchandise", "3D Design"] },
{ id: "digital-marketing", name: "Digital Marketing", icon: "📈", color: "bg-emerald-900/30 text-emerald-400", subcategories: ["Search", "Social", "Methods & Techniques", "Analytics & Strategy", "Channel Specific", "Industry & Purpose-Specific"] },
{ id: "writing-translation", name: "Writing & Translation", icon: "✍️", color: "bg-amber-900/30 text-amber-400", subcategories: ["Content Writing", "Editing & Critique", "Book & eBook Publishing", "Career Writing", "Business & Marketing Copy", "Translation & Transcription", "Industry Specific Content"] },
{ id: "video-animation", name: "Video & Animation", icon: "🎬", color: "bg-red-900/30 text-red-400", subcategories: ["Editing & Post-Production", "Social & Marketing Videos", "Animation", "Motion Graphics", "Filmed Production", "Explainer Videos", "AI Video"] },
{ id: "music-audio", name: "Music & Audio", icon: "🎵", color: "bg-purple-900/30 text-purple-400", subcategories: ["Production & Composition", "Engineering & Mixing", "Voice Over & Narration", "Streaming & Distribution", "DJing & Remixing", "Sound Design", "Lessons & Coaching"] },
{ id: "programming-tech", name: "Programming & Tech", icon: "💻", color: "bg-blue-900/30 text-blue-400", subcategories: ["Website Development", "Mobile App Development", "AI Development", "Game Development", "Cloud & Cybersecurity", "Data Science & Analytics", "Blockchain & Crypto", "DevOps & Infrastructure", "Chatbots & Automation", "API Development", "E-Commerce Development", "Support & IT"] },
{ id: "ai-services", name: "AI Services", icon: "🤖", color: "bg-cyan-900/30 text-cyan-400", subcategories: ["AI Development", "AI Artists", "AI Video", "AI Audio", "AI Content", "AI for Business", "AI Consulting"] },
{ id: "consulting", name: "Consulting", icon: "💼", color: "bg-slate-700/30 text-slate-400", subcategories: ["Business Consulting", "Marketing Strategy", "Data Consulting", "Coaching & Mentoring", "Tech Consulting", "Management Consulting"] },
{ id: "data", name: "Data", icon: "📊", color: "bg-teal-900/30 text-teal-400", subcategories: ["Data Entry", "Data Processing", "Data Analytics", "Data Visualization", "Data Science", "Data Mining", "Databases"] },
{ id: "business", name: "Business", icon: "🏢", color: "bg-indigo-900/30 text-indigo-400", subcategories: ["Financial Services", "Legal", "Management", "E-Commerce", "Sales", "Admin Support", "Project Management", "Customer Service", "HR & Recruiting"] },
{ id: "personal-growth", name: "Personal Growth", icon: "🌱", color: "bg-lime-900/30 text-lime-400", subcategories: ["Self Improvement", "Fashion & Style", "Wellness & Fitness", "Gaming", "Leisure", "Life Coaching", "Spiritual Guidance"] },
{ id: "photography", name: "Photography", icon: "📷", color: "bg-orange-900/30 text-orange-400", subcategories: ["Portrait Photography", "Product Photography", "Lifestyle Photography", "Real Estate Photography", "Event Photography", "Food Photography", "Drone Photography"] },
{ id: "finance", name: "Finance", icon: "💰", color: "bg-yellow-900/30 text-yellow-400", subcategories: ["Accounting", "Tax Services", "Corporate Finance", "FP&A", "Fundraising", "Wealth Management", "Financial Planning"] },
];
export async function GET() {
return NextResponse.json({ categories: CATEGORIES });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/souq/listings/[id] — single listing with seller info + reviews
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const listing = await prisma.listing.findUnique({
where: { id },
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
reviews: {
include: {
reviewer: {
select: { id: true, name: true, avatar: true },
},
},
orderBy: { createdAt: "desc" },
},
_count: {
select: { purchases: true },
},
},
});
if (!listing) {
return NextResponse.json(
{ error: "Listing not found" },
{ status: 404 }
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = listing as any;
return NextResponse.json({
listing: {
...data,
packages: data.packages ? JSON.parse(data.packages) : null,
tags: data.tags ? JSON.parse(data.tags) : null,
images: data.images ? JSON.parse(data.images) : null,
},
});
} catch (error) {
console.error("GET /api/souq/listings/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+182
View File
@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const featured = searchParams.get("featured");
const sellerId = searchParams.get("sellerId");
const minPrice = searchParams.get("minPrice");
const maxPrice = searchParams.get("maxPrice");
const sortBy = searchParams.get("sortBy");
const deliveryDays = searchParams.get("deliveryDays");
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
// Only return active listings by default
where.status = "active";
if (category) {
where.category = category;
}
if (search) {
where.OR = [
{ title: { contains: search } },
{ description: { contains: search } },
];
}
if (featured === "true") {
where.featured = true;
}
if (sellerId) {
where.sellerId = sellerId;
}
// Price range filter
if (minPrice || maxPrice) {
const priceFilter: Record<string, number> = {};
if (minPrice) priceFilter.gte = parseInt(minPrice, 10);
if (maxPrice) priceFilter.lte = parseInt(maxPrice, 10);
where.priceFlh = priceFilter;
}
// Delivery days filter
if (deliveryDays) {
where.deliveryDays = { lte: parseInt(deliveryDays, 10) };
}
// Build orderBy based on sortBy
let orderBy: Record<string, unknown>[];
switch (sortBy) {
case "price_asc":
orderBy = [{ priceFlh: "asc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "price_desc":
orderBy = [{ priceFlh: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "rating":
orderBy = [{ rating: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "popular":
orderBy = [{ salesCount: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "newest":
orderBy = [{ createdAt: "desc" }, { featured: "desc" }];
break;
default:
orderBy = [{ featured: "desc" }, { createdAt: "desc" }];
break;
}
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where,
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
orderBy,
skip,
take: limit,
}),
prisma.listing.count({ where }),
]);
return NextResponse.json({
listings,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("GET /api/souq/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const {
title,
description,
category,
subcategory,
priceFlh,
packages,
tags,
images,
deliveryDays,
fileType,
} = await request.json();
if (!title || !description || !category || !priceFlh) {
return NextResponse.json(
{ error: "Missing required fields: title, description, category, priceFlh" },
{ status: 400 }
);
}
if (typeof priceFlh !== "number" || priceFlh < 0) {
return NextResponse.json(
{ error: "priceFlh must be a non-negative number" },
{ status: 400 }
);
}
const listing = await prisma.listing.create({
data: {
title,
description,
category,
subcategory: subcategory || null,
priceFlh,
packages: packages ? JSON.stringify(packages) : null,
tags: tags ? JSON.stringify(tags) : null,
images: images ? JSON.stringify(images) : null,
deliveryDays: deliveryDays || null,
fileType: fileType || null,
sellerId: user.id,
status: "active",
},
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
return NextResponse.json({ listing }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+250
View File
@@ -0,0 +1,250 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/souq/orders/[id] — order detail
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const order = await prisma.purchase.findUnique({
where: { id },
include: {
listing: {
select: { id: true, title: true, category: true, subcategory: true, priceFlh: true, description: true, deliveryDays: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
if (!order) {
return NextResponse.json(
{ error: "Order not found" },
{ status: 404 }
);
}
// Verify user is either the buyer or seller
if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) {
return NextResponse.json(
{ error: "Forbidden: you are not a party to this order" },
{ status: 403 }
);
}
// Check if the current user (buyer) has already reviewed this order
let hasReviewed = false;
if (order.status === "completed" && order.buyerId === auth.userId) {
const existingReview = await prisma.review.findFirst({
where: { purchaseId: order.id, reviewerId: auth.userId },
select: { id: true },
});
hasReviewed = !!existingReview;
}
// Parse JSON fields
const data = order as any;
return NextResponse.json({
order: {
...data,
messages: data.messages ? JSON.parse(data.messages) : [],
deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [],
},
hasReviewed,
});
} catch (error) {
console.error("GET /api/souq/orders/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// PATCH /api/souq/orders/[id] — update order status or add messages
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const order = await prisma.purchase.findUnique({
where: { id },
});
if (!order) {
return NextResponse.json(
{ error: "Order not found" },
{ status: 404 }
);
}
// Verify user is either the buyer or seller
if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) {
return NextResponse.json(
{ error: "Forbidden: you are not a party to this order" },
{ status: 403 }
);
}
const body = await request.json();
const { status: newStatus, message, deliveryFiles } = body;
const updateData: Record<string, unknown> = {};
// Update status if provided
if (newStatus) {
const validStatuses = [
"pending",
"paid",
"in_progress",
"delivered",
"completed",
"disputed",
"cancelled",
];
if (!validStatuses.includes(newStatus)) {
return NextResponse.json(
{
error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`,
},
{ status: 400 }
);
}
// Validate status transitions
const validTransitions: Record<string, string[]> = {
paid: ["in_progress", "cancelled"],
in_progress: ["delivered", "disputed"],
delivered: ["completed", "disputed"],
completed: [],
disputed: ["completed", "cancelled"],
cancelled: [],
pending: ["paid", "cancelled"],
};
const allowed = validTransitions[order.status] || [];
if (!allowed.includes(newStatus)) {
return NextResponse.json(
{
error: `Cannot transition from "${order.status}" to "${newStatus}"`,
},
{ status: 400 }
);
}
// Only seller can mark as in_progress or delivered
if (
(newStatus === "in_progress" || newStatus === "delivered") &&
auth.userId !== order.sellerId
) {
return NextResponse.json(
{ error: "Only the seller can update to this status" },
{ status: 403 }
);
}
// Only buyer can mark as completed
if (newStatus === "completed" && auth.userId !== order.buyerId) {
return NextResponse.json(
{ error: "Only the buyer can mark an order as completed" },
{ status: 403 }
);
}
updateData.status = newStatus;
}
// Add a message if provided
if (message) {
if (typeof message !== "string" || message.trim().length === 0) {
return NextResponse.json(
{ error: "Message must be a non-empty string" },
{ status: 400 }
);
}
const existingMessages = order.messages
? (JSON.parse(order.messages) as Array<Record<string, unknown>>)
: [];
const newMessage = {
from: auth.userId,
text: message.trim(),
timestamp: new Date().toISOString(),
};
existingMessages.push(newMessage);
updateData.messages = JSON.stringify(existingMessages);
}
// Update delivery files if provided
if (deliveryFiles) {
if (!Array.isArray(deliveryFiles)) {
return NextResponse.json(
{ error: "deliveryFiles must be an array" },
{ status: 400 }
);
}
updateData.deliveryFiles = JSON.stringify(deliveryFiles);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: "No fields to update. Provide status, message, or deliveryFiles." },
{ status: 400 }
);
}
const updated = await prisma.purchase.update({
where: { id },
data: updateData,
include: {
listing: {
select: { id: true, title: true, category: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
const data = updated as any;
return NextResponse.json({
order: {
...data,
messages: data.messages ? JSON.parse(data.messages) : [],
deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [],
},
});
} catch (error) {
console.error("PATCH /api/souq/orders/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+213
View File
@@ -0,0 +1,213 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/souq/orders — my orders (as buyer or seller)
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const role = searchParams.get("role"); // "buyer" | "seller" | null (both)
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (role === "buyer") {
where.buyerId = auth.userId;
} else if (role === "seller") {
where.sellerId = auth.userId;
} else {
where.OR = [
{ buyerId: auth.userId },
{ sellerId: auth.userId },
];
}
const [orders, total] = await Promise.all([
prisma.purchase.findMany({
where,
include: {
listing: {
select: { id: true, title: true, category: true, priceFlh: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.purchase.count({ where }),
]);
// Parse messages JSON for each order
const parsed = orders.map((order) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const o = order as any;
return {
...o,
messages: o.messages ? JSON.parse(o.messages) : null,
deliveryFiles: o.deliveryFiles ? JSON.parse(o.deliveryFiles) : null,
};
});
return NextResponse.json({
orders: parsed,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("GET /api/souq/orders error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// POST /api/souq/orders — place a new order
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { listingId, packageName } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Validate listing exists
const listing = await prisma.listing.findUnique({
where: { id: listingId },
});
if (!listing) {
return NextResponse.json(
{ error: "Listing not found" },
{ status: 404 }
);
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "Listing is not available for purchase" },
{ status: 400 }
);
}
// Prevent buying own listing
if (listing.sellerId === user.id) {
return NextResponse.json(
{ error: "You cannot purchase your own listing" },
{ status: 400 }
);
}
// Determine amount: if packageName provided, look it up in packages JSON
let amountFlh = listing.priceFlh;
if (packageName) {
if (!listing.packages) {
return NextResponse.json(
{ error: "This listing has no packages" },
{ status: 400 }
);
}
const packages = JSON.parse(listing.packages) as Array<{
name: string;
price?: number;
}>;
const selectedPackage = packages.find((p) => p.name === packageName);
if (!selectedPackage) {
return NextResponse.json(
{ error: `Package "${packageName}" not found` },
{ status: 400 }
);
}
amountFlh = selectedPackage.price ?? listing.priceFlh;
}
// Check buyer balance
if (user.flhBalance < amountFlh) {
return NextResponse.json(
{ error: "Insufficient FLH balance" },
{ status: 400 }
);
}
// Calculate fees
const platformFee = Math.round(amountFlh * 0.015);
const sellerPayout = amountFlh - platformFee;
// Create purchase and deduct balance in a transaction
const [purchase] = await prisma.$transaction([
prisma.purchase.create({
data: {
listingId,
buyerId: user.id,
sellerId: listing.sellerId,
packageName: packageName || null,
amountFlh,
platformFee,
sellerPayout,
status: "paid",
},
include: {
listing: {
select: { id: true, title: true, category: true, priceFlh: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
}),
prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: amountFlh } },
}),
// Increment sales count on the listing
prisma.listing.update({
where: { id: listingId },
data: { salesCount: { increment: 1 } },
}),
]);
return NextResponse.json({ order: purchase }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/orders error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// POST /api/souq/reviews — create a review
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { listingId, orderId, rating, title, text } = await request.json();
// ── Validate fields ──
if (!listingId || !orderId) {
return NextResponse.json(
{ error: "Missing required fields: listingId, orderId" },
{ status: 400 }
);
}
if (!rating || typeof rating !== "number" || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
return NextResponse.json(
{ error: "Rating must be an integer between 1 and 5" },
{ status: 400 }
);
}
// ── Ensure the order exists and belongs to this user ──
const order = await prisma.purchase.findUnique({
where: { id: orderId },
include: { listing: { select: { id: true, sellerId: true } } },
});
if (!order) {
return NextResponse.json({ error: "Order not found" }, { status: 404 });
}
if (order.buyerId !== auth.userId) {
return NextResponse.json(
{ error: "Only the buyer can review this order" },
{ status: 403 }
);
}
if (order.status !== "completed") {
return NextResponse.json(
{ error: "Can only review completed orders" },
{ status: 400 }
);
}
if (order.listingId !== listingId) {
return NextResponse.json(
{ error: "Listing does not match this order" },
{ status: 400 }
);
}
// ── Check if user already reviewed this purchase ──
const existing = await prisma.review.findFirst({
where: { purchaseId: orderId, reviewerId: auth.userId },
});
if (existing) {
return NextResponse.json(
{ error: "You have already reviewed this order" },
{ status: 409 }
);
}
// ── Create the review and update listing stats in a transaction ──
const [review] = await prisma.$transaction(async (tx) => {
const created = await tx.review.create({
data: {
listingId,
purchaseId: orderId,
reviewerId: auth.userId,
sellerId: order.listing.sellerId,
rating,
title: title || null,
text: text ?? "",
},
});
// Recalculate listing average rating and count
const agg = await tx.review.aggregate({
where: { listingId },
_avg: { rating: true },
_count: { rating: true },
});
await tx.listing.update({
where: { id: listingId },
data: {
rating: agg._avg.rating ?? 0,
reviewCount: agg._count.rating,
},
});
return [created];
});
return NextResponse.json({ review }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/reviews error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/souq/sellers/[id] — seller profile data
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get user info
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
avatar: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: "Seller not found" },
{ status: 404 }
);
}
// Get review stats
const reviewAgg = await prisma.review.aggregate({
where: { sellerId: id },
_avg: { rating: true },
_count: true,
});
// Get sales count
const salesCount = await prisma.purchase.count({
where: { sellerId: id },
});
// Get active listings
const listings = await prisma.listing.findMany({
where: { sellerId: id, status: "active" },
orderBy: { createdAt: "desc" },
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
// Parse JSON fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedListings = listings.map((l: any) => ({
...l,
packages: l.packages ? JSON.parse(l.packages) : null,
tags: l.tags ? JSON.parse(l.tags) : null,
images: l.images ? JSON.parse(l.images) : null,
}));
return NextResponse.json({
seller: {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
memberSince: user.createdAt,
listingsCount: parsedListings.length,
averageRating: reviewAgg._avg.rating ?? 0,
reviewCount: reviewAgg._count,
salesCount,
listings: parsedListings,
},
});
} catch (error) {
console.error("GET /api/souq/sellers/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { writeFile } from "fs/promises";
import path from "path";
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_MIME = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/x-7z-compressed",
"application/gzip",
];
// POST /api/souq/upload — upload a delivery file
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File | null;
const orderId = formData.get("orderId") as string | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!orderId) {
return NextResponse.json(
{ error: "orderId is required" },
{ status: 400 }
);
}
// Check file size
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "File too large. Maximum 10 MB." },
{ status: 413 }
);
}
// Only check mime type if available; allow fallback for unknown types
if (file.type && !ALLOWED_MIME.includes(file.type)) {
return NextResponse.json(
{
error:
"Invalid file type. Allowed: images, PDFs, ZIP, RAR, 7z, GZIP.",
},
{ status: 400 }
);
}
// Generate unique filename
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileName = `${orderId}-${timestamp}-${safeName}`;
// Save to disk
const uploadDir = path.join(
process.cwd(),
"public",
"uploads",
"deliveries"
);
const filePath = path.join(uploadDir, fileName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Return the public URL path
const url = `/uploads/deliveries/${fileName}`;
return NextResponse.json({
url,
name: file.name,
size: file.size,
});
} catch (error) {
console.error("POST /api/souq/upload error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -3,9 +3,11 @@ import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
const TOP_UP_AMOUNTS = {
500: { usd: 4.99 },
1100: { usd: 9.99 },
3000: { usd: 24.99 },
500: { usd: 0.99, bonus: 0, priceEnv: "POLAR_FLH_500" },
1000: { usd: 0.99, bonus: 0, priceEnv: "POLAR_FLH_1000" },
5000: { usd: 4.99, bonus: 500, priceEnv: "POLAR_FLH_5000" },
10000: { usd: 9.99, bonus: 2000, priceEnv: "POLAR_FLH_10000" },
50000: { usd: 49.99, bonus: 15000, priceEnv: "POLAR_FLH_50000" },
} as const;
export async function POST(req: NextRequest) {
@@ -20,22 +22,19 @@ export async function POST(req: NextRequest) {
if (!amount || !(amount in TOP_UP_AMOUNTS)) {
return NextResponse.json(
{ error: "Invalid amount. Choose 500, 1100, or 3000 FLH." },
{ error: "Invalid amount. Choose 500, 1000, 5000, 10000, or 50000 FLH." },
{ status: 400 }
);
}
const pricing = TOP_UP_AMOUNTS[amount as keyof typeof TOP_UP_AMOUNTS];
const totalFlh = amount + (pricing.bonus || 0);
// In production, this would create a Polar.sh checkout session.
// For development, we mock the flow by directly adding FLH balance.
// Replace with actual Polar API integration when ready.
const useMock = process.env.MOCK_PAYMENTS !== "false";
if (useMock) {
// Mock mode: use when POLAR_ACCESS_TOKEN is not configured
if (!process.env.POLAR_ACCESS_TOKEN) {
await prisma.user.update({
where: { id: jwtPayload.userId },
data: { flhBalance: { increment: amount } },
data: { flhBalance: { increment: totalFlh } },
});
return NextResponse.json({
@@ -43,27 +42,67 @@ export async function POST(req: NextRequest) {
url: "/wallet?topup=success",
mock: true,
amount,
bonus: pricing.bonus || 0,
amountUsd: pricing.usd,
});
}
// Production path — Polar checkout creation
// const polarSession = await createPolarCheckout({
// amount,
// customerId: user.stripeCustomerId,
// metadata: { userId: jwtPayload.userId, type: "top-up", amount },
// });
// Production — Polar.sh checkout session
const productPriceId = process.env[pricing.priceEnv];
if (!productPriceId) {
return NextResponse.json(
{ error: `Polar price not configured for ${amount} FLH. Set ${pricing.priceEnv} env var.` },
{ status: 500 }
);
}
const origin = req.headers.get("origin") || req.headers.get("host") || "";
const baseUrl = origin.startsWith("http") ? origin : `https://${origin}`;
const polarResponse = await fetch("https://api.polar.sh/v1/checkouts/", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POLAR_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_price_id: productPriceId,
success_url: `${baseUrl}/wallet?topup=success&amount=${amount}`,
customer_email: jwtPayload.email,
return_url: `${baseUrl}/wallet`,
metadata: {
type: "flh_purchase",
flh_amount: totalFlh,
user_id: jwtPayload.userId,
user_email: jwtPayload.email,
},
allow_trial: false,
}),
});
if (!polarResponse.ok) {
const errorBody = await polarResponse.text();
console.error("Polar API error:", polarResponse.status, errorBody);
return NextResponse.json(
{ error: `Checkout creation failed (HTTP ${polarResponse.status})` },
{ status: 502 }
);
}
const polarData = await polarResponse.json();
return NextResponse.json({
success: true,
url: "/wallet?topup=success", // would be polarSession.url
url: polarData.url,
checkout_id: polarData.id,
amount,
amountUsd: pricing.usd,
bonus: pricing.bonus || 0,
});
} catch (error) {
console.error("Top-up checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout" },
{ error: error instanceof Error ? error.message : "Failed to create checkout" },
{ status: 500 }
);
}
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Polar.sh webhook for FLH purchase checkouts
// Receives checkout.completed events and credits the buyer's FLH balance
export async function POST(request: NextRequest) {
try {
// Log the incoming webhook for debugging
const body = await request.json();
const eventType = body.type || "";
console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: body.data?.id }));
// Only process checkout events
if (!eventType.startsWith("checkout.")) {
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
}
// Verify webhook secret if configured
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
if (webhookSecret) {
const signature = request.headers.get("polar-signature") || "";
// In production, verify the signature using crypto.timingSafeEqual
// For now, use the secret as a simple check
if (signature !== webhookSecret) {
console.warn("[polar-webhook] Invalid signature");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
const checkout = body.data || {};
const metadata = checkout.metadata || {};
// Only process FLH purchase events
if (metadata.type !== "flh_purchase") {
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
}
// Only process completed/paid checkouts
if (checkout.status !== "succeeded" && checkout.status !== "paid") {
return NextResponse.json(
{ received: true, status: checkout.status },
{ status: 200 }
);
}
const userId = metadata.user_id;
const flhAmount = parseInt(metadata.flh_amount, 10);
if (!userId || !flhAmount || flhAmount < 1) {
console.error("[polar-webhook] Invalid metadata:", { userId, flhAmount });
return NextResponse.json(
{ error: "Invalid metadata: userId and flh_amount required" },
{ status: 400 }
);
}
// Check if this checkout was already processed (idempotency)
const checkoutId = checkout.id || body.id;
if (checkoutId) {
const existing = await prisma.xpTransaction.findFirst({
where: {
userId,
reason: `flh_purchase_${checkoutId}`,
},
});
if (existing) {
console.log(`[polar-webhook] Checkout ${checkoutId} already processed, skipping`);
return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
}
}
// Credit the user's FLH balance
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
console.error(`[polar-webhook] User not found: ${userId}`);
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
await prisma.user.update({
where: { id: userId },
data: { flhBalance: { increment: flhAmount } },
});
// Record the transaction for idempotency and audit
if (checkoutId) {
await prisma.xpTransaction.create({
data: {
userId,
amount: flhAmount,
reason: `flh_purchase_${checkoutId}`,
},
});
}
console.log(`[polar-webhook] Credited ${flhAmount} FLH to user ${userId}. New balance: ${user.flhBalance + flhAmount}`);
return NextResponse.json(
{
received: true,
success: true,
flhCredited: flhAmount,
userId,
newBalance: user.flhBalance + flhAmount,
},
{ status: 200 }
);
} catch (error) {
console.error("[polar-webhook] Error:", error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}
+273 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
/**
@@ -20,7 +21,38 @@ export async function POST(req: NextRequest) {
}
// Production path: Parse Polar webhook event
const body = await req.json();
const rawBody = await req.text();
// Verify webhook signature
const signature = req.headers.get("webhook-id") || req.headers.get("polar-signature");
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
if (webhookSecret) {
if (!signature) {
console.error("Polar webhook signature missing");
return NextResponse.json(
{ error: "Missing webhook signature" },
{ status: 401 }
);
}
const expectedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(rawBody)
.digest("hex");
if (signature !== expectedSignature) {
console.error("Polar webhook signature mismatch");
return NextResponse.json(
{ error: "Invalid webhook signature" },
{ status: 401 }
);
}
} else {
console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)");
}
const body = JSON.parse(rawBody);
const event = body.type || body.event;
if (!event) {
@@ -43,6 +75,39 @@ export async function POST(req: NextRequest) {
);
}
const metadataType = checkout.metadata?.type;
const customerEmail = checkout.customer?.email;
// --- Course purchase flow ---
if (metadataType === "course_purchase") {
const courseSlug = checkout.metadata?.course_slug;
if (!courseSlug) {
return NextResponse.json(
{ error: "Missing course_slug in metadata for course_purchase" },
{ status: 400 }
);
}
if (!customerEmail) {
return NextResponse.json(
{ error: "Missing customer email for course_purchase" },
{ status: 400 }
);
}
return handleCoursePurchase(customerEmail, courseSlug);
}
// --- Learn subscription flow ---
if (metadataType === "learn_subscription") {
if (!customerEmail) {
return NextResponse.json(
{ error: "Missing customer email for learn_subscription" },
{ status: 400 }
);
}
return handleLearnSubscription(customerEmail, checkout);
}
// --- Fallback: premium/pro upgrade (existing logic) ---
const userId = checkout.metadata?.userId;
const priceId = checkout.product?.priceId ||
checkout.productPrice?.id ||
@@ -64,6 +129,11 @@ export async function POST(req: NextRequest) {
return applyUpgrade(userId, tier);
}
// Handle subscription.cancelled and subscription.revoked events
if (event === "subscription.cancelled" || event === "subscription.revoked") {
return handleSubscriptionCancelled(body.data);
}
// Acknowledge other events silently
return NextResponse.json({ received: true });
} catch (error) {
@@ -116,3 +186,205 @@ async function applyUpgrade(userId: string, tier: "premium" | "pro") {
trialEndsAt,
});
}
/**
* Handle a course_purchase checkout.
* Looks up the user by email, finds the LearnCourse by slug,
* and creates (or re-activates) a LearnEnrollment with purchaseType='one_time'.
*/
async function handleCoursePurchase(customerEmail: string, courseSlug: string) {
// Find the user by email
const user = await prisma.user.findUnique({ where: { email: customerEmail } });
if (!user) {
return NextResponse.json(
{ error: "User not found for email" },
{ status: 404 }
);
}
// Find the course by slug
const course = await prisma.learnCourse.findUnique({ where: { slug: courseSlug } });
if (!course) {
return NextResponse.json(
{ error: "Course not found" },
{ status: 404 }
);
}
// Upsert enrollment: create or re-activate on re-purchase
await prisma.learnEnrollment.upsert({
where: {
userId_courseId: { userId: user.id, courseId: course.id },
},
create: {
userId: user.id,
courseId: course.id,
purchaseType: "one_time",
progress: 0,
startedAt: new Date(),
},
update: {
purchaseType: "one_time",
completed: false,
progress: 0,
completedAt: null,
startedAt: new Date(),
},
});
return NextResponse.json({
success: true,
type: "course_purchase",
userId: user.id,
courseId: course.id,
courseSlug,
});
}
/**
* Handle a learn_subscription checkout.
* Looks up the user by email and creates/updates a LearnSubscription record.
* Uses Polar subscription data for period management.
*/
async function handleLearnSubscription(customerEmail: string, checkout: any) {
// Find the user by email
const user = await prisma.user.findUnique({ where: { email: customerEmail } });
if (!user) {
return NextResponse.json(
{ error: "User not found for email" },
{ status: 404 }
);
}
// Extract subscription details from checkout data
const polarSubId =
checkout.subscription?.id ||
checkout.metadata?.subscriptionId ||
null;
const now = new Date();
const currentPeriodStart = checkout.subscription?.currentPeriodStart
? new Date(checkout.subscription.currentPeriodStart)
: now;
const currentPeriodEnd = checkout.subscription?.currentPeriodEnd
? new Date(checkout.subscription.currentPeriodEnd)
: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default 30 days
// Upsert subscription record per user (one active subscription per user)
if (polarSubId) {
await prisma.learnSubscription.upsert({
where: { polarSubId },
create: {
userId: user.id,
polarSubId,
status: "active",
currentPeriodStart,
currentPeriodEnd,
},
update: {
userId: user.id,
status: "active",
currentPeriodStart,
currentPeriodEnd,
cancelledAt: null,
},
});
} else {
// No polarSubId: check if user already has a subscription and update it
const existing = await prisma.learnSubscription.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
if (existing) {
await prisma.learnSubscription.update({
where: { id: existing.id },
data: {
status: "active",
currentPeriodStart,
currentPeriodEnd,
cancelledAt: null,
},
});
} else {
await prisma.learnSubscription.create({
data: {
userId: user.id,
status: "active",
currentPeriodStart,
currentPeriodEnd,
},
});
}
}
// Also update the User's premium status to match the active subscription
await prisma.user.update({
where: { id: user.id },
data: {
isPremium: true,
trialEndsAt: currentPeriodEnd,
},
});
return NextResponse.json({
success: true,
type: "learn_subscription",
userId: user.id,
});
}
/**
* Handle a subscription.cancelled or subscription.revoked event.
* Sets the LearnSubscription status to 'cancelled' and removes the user's isPremium flag.
*/
async function handleSubscriptionCancelled(data: any) {
// Extract the Polar subscription ID from the event data
const polarSubId =
data?.id ||
data?.subscription?.id ||
data?.metadata?.subscriptionId ||
null;
if (!polarSubId) {
return NextResponse.json(
{ error: "Missing subscription ID in event data" },
{ status: 400 }
);
}
// Find the LearnSubscription by the Polar subscription ID
const subscription = await prisma.learnSubscription.findUnique({
where: { polarSubId },
});
if (!subscription) {
return NextResponse.json(
{ error: "LearnSubscription not found for polarSubId" },
{ status: 404 }
);
}
// Mark the subscription as cancelled
await prisma.learnSubscription.update({
where: { id: subscription.id },
data: {
status: "cancelled",
cancelledAt: new Date(),
},
});
// Remove the user's premium flag
await prisma.user.update({
where: { id: subscription.userId },
data: {
isPremium: false,
},
});
return NextResponse.json({
success: true,
type: "subscription_cancelled",
userId: subscription.userId,
});
}