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

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

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

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

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

Infrastructure:
- Dockerfile: standalone output, Prisma runtime migration
- docker-compose.yml: Polar env vars, healthcheck fix
- docker-compose.staging.yml: staging on port 4014
- .gitea/workflows/ci.yml: CI pipeline
This commit is contained in:
root
2026-06-24 07:02:03 +02:00
parent 913fa94fb9
commit cfff74e2db
81 changed files with 12132 additions and 2101 deletions
@@ -1,144 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
type Provider,
exchangeCode,
findOrCreateOAuthUser,
verifyState,
} from "@/lib/oauth";
import { signJWT } from "@/lib/auth";
const VALID_PROVIDERS = ["google", "github"];
async function handleCallback(
req: NextRequest,
provider: string
): Promise<NextResponse> {
try {
// Grab code and state from query params or POST body
const url = new URL(req.url);
let code = url.searchParams.get("code");
let stateParam = url.searchParams.get("state");
// Try reading POST body if code wasn't in query params
if (!code) {
try {
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("form")) {
const formData = await req.formData();
code = formData.get("code") as string | null;
stateParam = formData.get("state") as string | null;
}
} catch {
// not a form POST, that's fine
}
}
if (!code) {
return NextResponse.json(
{ error: "Missing authorization code" },
{ status: 400 }
);
}
if (!stateParam) {
return NextResponse.json(
{ error: "Missing state parameter" },
{ status: 400 }
);
}
// Verify state from cookie (CSRF protection)
const cookieStore = await cookies();
const storedState = cookieStore.get("oauth_state")?.value;
if (!storedState) {
return NextResponse.json(
{
error: "Missing state cookie. Please start the OAuth flow again.",
},
{ status: 400 }
);
}
const statePayload = await verifyState(stateParam);
if (!statePayload) {
return NextResponse.json(
{
error:
"Invalid or expired state. Please start the OAuth flow again.",
},
{ status: 400 }
);
}
// Verify the state and provider match
if (statePayload.provider !== provider) {
return NextResponse.json(
{ error: "State/provider mismatch" },
{ status: 400 }
);
}
// Clear the state cookie
cookieStore.set("oauth_state", "", {
httpOnly: true,
path: "/",
maxAge: 0,
});
// Build the redirect URI (must match the one used in the authorization request)
const forwardedProto = req.headers.get("x-forwarded-proto") || "https";
const rawHost = req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Exchange code for user info
const providerUser = await exchangeCode(
provider as Provider,
code,
redirectUri
);
// Find or create user in database
const user = await findOrCreateOAuthUser(
provider as Provider,
providerUser
);
// Generate JWT
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
// Redirect to frontend callback page with token
const frontendUrl = `${baseUrl}/mobile/auth/callback?token=${encodeURIComponent(token)}`;
return NextResponse.redirect(frontendUrl);
} catch (error) {
console.error(`${provider} OAuth callback error:`, error);
const message =
error instanceof Error ? error.message : "OAuth callback failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json(
{
error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
},
{ status: 400 }
);
}
return handleCallback(req, provider);
}
-68
View File
@@ -1,68 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
type Provider,
PROVIDER_CONFIGS,
generateState,
buildAuthorizationUrl,
} from "@/lib/oauth";
const VALID_PROVIDERS = ["google", "github"];
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json(
{ error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}` },
{ status: 400 }
);
}
const config = PROVIDER_CONFIGS[provider as Provider];
const clientId = process.env[config.clientIdEnv];
if (!clientId) {
return NextResponse.json(
{
error: `${provider} OAuth is not configured. Missing ${config.clientIdEnv} environment variable.`,
},
{ status: 503 }
);
}
// Build the redirect URI — the callback URL for this provider
// Use forwarded headers (Traefik/Cloudflare) or the original Host header
// to ensure the public domain is used, not the Docker container hostname.
const forwardedProto = _req.headers.get("x-forwarded-proto") || "https";
const rawHost = _req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = _req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Generate state for CSRF protection
const state = await generateState(provider as Provider);
// Store state in a cookie
const cookieStore = await cookies();
cookieStore.set("oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 10, // 10 minutes
});
// Build the authorization URL and redirect
const authUrl = buildAuthorizationUrl(
provider as Provider,
state,
redirectUri
);
return NextResponse.redirect(authUrl);
}
+38 -30
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { UMMAHID_URL } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
@@ -14,39 +13,48 @@ export async function POST(req: NextRequest) {
);
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.passwordHash) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
// Proxy to Ummah ID
const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const ummahData = await ummahRes.json();
if (!ummahRes.ok) {
return NextResponse.json(
{ error: ummahData.error || "Authentication failed" },
{ status: ummahRes.status }
);
}
const { token, user: ummahUser } = ummahData;
// Find or create local user by email
let localUser = await prisma.user.findUnique({ where: { email } });
if (!localUser) {
localUser = await prisma.user.create({
data: {
email,
name: ummahUser?.name || email?.split("@")[0] || "User",
provider: "ummahid",
providerId: ummahUser.id,
walletBalance: 5000,
},
});
}
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
id: localUser.id,
email: localUser.email,
name: localUser.name,
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
dailyMsgCount: localUser.dailyMsgCount,
},
});
} catch (error) {
+33 -85
View File
@@ -1,15 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { findReferrerByCode } from "@/lib/referral";
const REFERRER_REWARD_FLH = 200;
const REFERRED_BONUS_FLH = 1000;
import { UMMAHID_URL } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { email, name, password, referralCode } = await req.json();
const { email, name, password } = await req.json();
if (!email || !name || !password) {
return NextResponse.json(
@@ -18,96 +13,49 @@ export async function POST(req: NextRequest) {
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
// Proxy to Ummah ID
const ummahRes = await fetch(`${UMMAHID_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name, password }),
});
const ummahData = await ummahRes.json();
if (!ummahRes.ok) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
{ error: ummahData.error || "Registration failed" },
{ status: ummahRes.status }
);
}
const passwordHash = await bcrypt.hash(password, 12);
const { token, user: ummahUser } = ummahData;
// Look up referrer if a referral code was provided
let referrerId: string | null = null;
if (referralCode && typeof referralCode === "string") {
const users = await prisma.user.findMany({
select: { id: true },
// Create or find local user (find first to avoid duplicates)
let localUser = await prisma.user.findUnique({ where: { email } });
if (!localUser) {
localUser = await prisma.user.create({
data: {
email,
name: ummahUser?.name || name,
provider: "ummahid",
flhBalance: 5000,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
referrerId = await findReferrerByCode(users, referralCode);
}
// Calculate starting balance
let startingBalance = 5000; // default sign-up bonus
if (referrerId) {
startingBalance += REFERRED_BONUS_FLH; // +1,000 FLH for using a referral code
}
const user = await prisma.user.create({
data: {
email,
name,
passwordHash,
provider: "email",
flhBalance: startingBalance,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// If referral code was valid, create the referral record and credit the referrer
if (referrerId) {
await prisma.$transaction([
// Create the referral record
prisma.referral.create({
data: {
referrerId,
referredId: user.id,
status: "joined",
rewardFlh: REFERRER_REWARD_FLH,
},
}),
// Credit the referrer
prisma.user.update({
where: { id: referrerId },
data: { flhBalance: { increment: REFERRER_REWARD_FLH } },
}),
// Notify the referrer
prisma.notification.create({
data: {
userId: referrerId,
type: "referral_bonus",
title: "Referral Bonus Earned!",
body: `${name} joined Falah using your referral code — you earned ${REFERRER_REWARD_FLH} FLH!`,
data: JSON.stringify({
referredUserId: user.id,
referredName: name,
rewardFlh: REFERRER_REWARD_FLH,
}),
},
}),
]);
}
const token = await signJWT({
userId: user.id,
email: user.email,
isPremium: user.isPremium,
isPro: user.isPro,
});
return NextResponse.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
id: localUser.id,
email: localUser.email,
name: localUser.name,
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
dailyMsgCount: localUser.dailyMsgCount,
},
referralApplied: referrerId ? true : false,
referralReward: referrerId ? REFERRED_BONUS_FLH : 0,
});
} catch (error) {
console.error("Register error:", error);