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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user