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,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