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);
|
||||
|
||||
Reference in New Issue
Block a user