Block 8 complete: streaks, notifications, referrals, premium identity, gamification

This commit is contained in:
root
2026-06-15 10:32:10 +02:00
parent 6423430275
commit 5e73d1d7ad
24 changed files with 1565 additions and 38 deletions
@@ -0,0 +1,45 @@
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
// GET /api/gamification/achievements — get all achievements with unlock status
export async function GET(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = jwt.userId;
const achievements = await prisma.achievement.findMany({
where: { userId },
orderBy: { unlockedAt: "desc" },
});
// Define all possible achievement types so the frontend can show locked ones too
const allAchievementTypes = [
{ type: "first_chat", title: "First Words", description: "Send your first message to Nur AI", icon: "💬" },
{ type: "streak_7", title: "Week Warrior", description: "Maintain a 7-day streak", icon: "🔥" },
{ type: "streak_30", title: "Monthly Master", description: "Maintain a 30-day streak", icon: "🏆" },
{ type: "level_5", title: "Rising Star", description: "Reach level 5", icon: "⭐" },
{ type: "first_purchase", title: "First Shopper", description: "Make your first marketplace purchase", icon: "🛍️" },
{ type: "first_post", title: "Forum Newbie", description: "Make your first forum post", icon: "📝" },
];
const unlocked = new Set(achievements.map((a) => a.type));
const allAchievements = allAchievementTypes.map((def) => {
const unlockedAchievement = achievements.find((a) => a.type === def.type);
return {
...def,
unlocked: unlocked.has(def.type),
unlockedAt: unlockedAchievement?.unlockedAt ?? null,
};
});
return NextResponse.json({
achievements: allAchievements,
unlockedCount: achievements.length,
totalCount: allAchievementTypes.length,
});
}
+275
View File
@@ -0,0 +1,275 @@
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
const STREAK_REWARDS: Record<number, number> = {
1: 100,
3: 300,
7: 500,
14: 1000,
30: 2500,
};
function getStreakReward(streak: number): number {
const milestones = Object.keys(STREAK_REWARDS)
.map(Number)
.sort((a, b) => b - a);
for (const m of milestones) {
if (streak >= m) return STREAK_REWARDS[m];
}
return 0;
}
function getXpForStreakClaim(streak: number): number {
return Math.min(10 + streak * 2, 100);
}
function isYesterday(d: Date): boolean {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
return (
d.getFullYear() === yesterday.getFullYear() &&
d.getMonth() === yesterday.getMonth() &&
d.getDate() === yesterday.getDate()
);
}
function isToday(d: Date): boolean {
const now = new Date();
return (
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
);
}
// GET /api/gamification/streak — get current streak info
export async function GET(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let streak = await prisma.dailyStreak.findUnique({
where: { userId: jwt.userId },
});
if (!streak) {
streak = await prisma.dailyStreak.create({
data: { userId: jwt.userId },
});
}
const canClaim =
!streak.lastClaimDate || !isToday(streak.lastClaimDate);
const todayReward = getStreakReward(streak.currentStreak + (canClaim ? 1 : 0));
return NextResponse.json({
currentStreak: streak.currentStreak,
longestStreak: streak.longestStreak,
lastClaimDate: streak.lastClaimDate,
canClaim,
streakFreeze: streak.streakFreeze,
todayReward,
});
}
// POST /api/gamification/streak — claim daily streak reward
export async function POST(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = jwt.userId;
// Get or create streak record
let streak = await prisma.dailyStreak.findUnique({
where: { userId },
});
if (!streak) {
streak = await prisma.dailyStreak.create({
data: { userId },
});
}
// Already claimed today
if (streak.lastClaimDate && isToday(streak.lastClaimDate)) {
return NextResponse.json(
{ error: "Already claimed today" },
{ status: 409 }
);
}
let newStreak: number;
let newLongest: number;
if (!streak.lastClaimDate) {
// First claim ever
newStreak = 1;
newLongest = 1;
} else if (isYesterday(streak.lastClaimDate)) {
// Consecutive day
newStreak = streak.currentStreak + 1;
newLongest = Math.max(newStreak, streak.longestStreak);
} else {
// Gap > 1 day — check streak freeze
if (streak.streakFreeze > 0) {
// Use a freeze — keep streak intact
newStreak = streak.currentStreak + 1;
newLongest = Math.max(newStreak, streak.longestStreak);
// Decrement freeze
await prisma.dailyStreak.update({
where: { userId },
data: { streakFreeze: { decrement: 1 } },
});
} else {
// Reset streak
newStreak = 1;
newLongest = streak.longestStreak; // keep longest
}
}
const reward = getStreakReward(newStreak);
const xpGain = getXpForStreakClaim(newStreak);
// Update streak
await prisma.dailyStreak.update({
where: { userId },
data: {
currentStreak: newStreak,
longestStreak: newLongest,
lastClaimDate: new Date(),
},
});
// Award FLH
if (reward > 0) {
await prisma.user.update({
where: { id: userId },
data: { flhBalance: { increment: reward } },
});
}
// Award XP + create transaction
const xpTx = await prisma.xpTransaction.create({
data: {
userId,
amount: xpGain,
reason: "daily_login",
},
});
// Update or create user level
let userLevel = await prisma.userLevel.findUnique({
where: { userId },
});
if (!userLevel) {
userLevel = await prisma.userLevel.create({
data: { userId, xp: xpGain },
});
} else {
userLevel = await prisma.userLevel.update({
where: { userId },
data: { xp: { increment: xpGain } },
});
}
// Check level up
let newLevel = userLevel.level;
let newXp = userLevel.xp;
let newXpToNext = userLevel.xpToNext;
let leveledUp = false;
while (newXp >= newXpToNext) {
newXp -= newXpToNext;
newLevel++;
newXpToNext = Math.floor(newXpToNext * 1.5);
leveledUp = true;
}
if (leveledUp) {
await prisma.userLevel.update({
where: { userId },
data: {
level: newLevel,
xp: newXp,
xpToNext: newXpToNext,
},
});
}
// Check achievements
const achievements: string[] = [];
// Streak 7 days
if (newStreak >= 7) {
const exists = await prisma.achievement.findUnique({
where: { userId_type: { userId, type: "streak_7" } },
});
if (!exists) {
await prisma.achievement.create({
data: {
userId,
type: "streak_7",
title: "Week Warrior",
description: "Maintained a 7-day streak",
icon: "🔥",
},
});
achievements.push("streak_7");
}
}
// Streak 30 days
if (newStreak >= 30) {
const exists = await prisma.achievement.findUnique({
where: { userId_type: { userId, type: "streak_30" } },
});
if (!exists) {
await prisma.achievement.create({
data: {
userId,
type: "streak_30",
title: "Monthly Master",
description: "Maintained a 30-day streak",
icon: "🏆",
},
});
achievements.push("streak_30");
}
}
// Level 5
if (newLevel >= 5 && leveledUp) {
const exists = await prisma.achievement.findUnique({
where: { userId_type: { userId, type: "level_5" } },
});
if (!exists) {
await prisma.achievement.create({
data: {
userId,
type: "level_5",
title: "Rising Star",
description: "Reached level 5",
icon: "⭐",
},
});
achievements.push("level_5");
}
}
return NextResponse.json({
currentStreak: newStreak,
longestStreak: newLongest,
lastClaimDate: new Date(),
reward,
xpGain,
newLevel: leveledUp ? newLevel : undefined,
achievements,
});
}
+55
View File
@@ -0,0 +1,55 @@
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
// GET /api/gamification/xp — get user XP, level, and recent transactions
export async function GET(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = jwt.userId;
// Get or create user level
let userLevel = await prisma.userLevel.findUnique({
where: { userId },
});
if (!userLevel) {
userLevel = await prisma.userLevel.create({
data: { userId },
});
}
// Get recent transactions
const recentTransactions = await prisma.xpTransaction.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json({
level: userLevel.level,
xp: userLevel.xp,
xpToNext: userLevel.xpToNext,
totalXp: userLevel.xp + (userLevel.level - 1) * calculateXpForLevel(userLevel.level),
recentTransactions: recentTransactions.map((tx) => ({
id: tx.id,
amount: tx.amount,
reason: tx.reason,
createdAt: tx.createdAt,
})),
});
}
function calculateXpForLevel(level: number): number {
// Base XP per level: 100 * 1.5^(level-1) cumulative
let total = 0;
let xpToNext = 100;
for (let i = 1; i < level; i++) {
total += xpToNext;
xpToNext = Math.floor(xpToNext * 1.5);
}
return total;
}
+13 -5
View File
@@ -29,7 +29,7 @@ export async function POST(request: NextRequest) {
where: { id: listingId },
include: {
seller: {
select: { id: true, isPro: true },
select: { id: true, isPremium: true, isPro: true },
},
},
});
@@ -64,10 +64,14 @@ export async function POST(request: NextRequest) {
// Calculate platform fee
// Pro sellers pay 0% platform fee, free/premium pay 1.5%
const sellerTier = getUserTier(false, listing.seller.isPro);
const sellerTier = getUserTier(listing.seller.isPremium, listing.seller.isPro);
const platformFeeRate = sellerTier === "pro" ? 0 : 0.015;
const platformFee = Math.round(listing.priceFlh * platformFeeRate);
const sellerPayout = listing.priceFlh - platformFee;
// 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);
@@ -99,7 +103,7 @@ export async function POST(request: NextRequest) {
where: { id: buyer.id },
data: { flhBalance: { decrement: listing.priceFlh } },
}),
// Add payout to seller
// Add payout to seller (with multiplier applied)
prisma.user.update({
where: { id: listing.sellerId },
data: { flhBalance: { increment: sellerPayout } },
@@ -111,10 +115,14 @@ export async function POST(request: NextRequest) {
}),
]);
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.`,
message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.${multiplierNote}`,
},
{ status: 201 }
);
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const unreadOnly = searchParams.get("unread") === "true";
const where: any = { userId: jwtPayload.userId };
if (unreadOnly) where.read = false;
const notifications = await prisma.notification.findMany({
where,
orderBy: { createdAt: "desc" },
take: 50,
});
return NextResponse.json({ notifications });
} catch (error) {
console.error("Notifications GET error:", error);
return NextResponse.json(
{ error: "Failed to fetch notifications" },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
if (body.all === true) {
await prisma.notification.updateMany({
where: { userId: jwtPayload.userId, read: false },
data: { read: true },
});
return NextResponse.json({ success: true, markedAll: true });
}
if (body.id) {
const notification = await prisma.notification.findFirst({
where: { id: body.id, userId: jwtPayload.userId },
});
if (!notification) {
return NextResponse.json(
{ error: "Notification not found" },
{ status: 404 }
);
}
await prisma.notification.update({
where: { id: body.id },
data: { read: true },
});
return NextResponse.json({ success: true });
}
return NextResponse.json(
{ error: "Provide 'id' or { all: true }" },
{ status: 400 }
);
} catch (error) {
console.error("Notifications PATCH error:", error);
return NextResponse.json(
{ error: "Failed to update notification" },
{ status: 500 }
);
}
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const count = await prisma.notification.count({
where: { userId: jwtPayload.userId, read: false },
});
return NextResponse.json({ count });
} catch (error) {
console.error("Unread count error:", error);
return NextResponse.json(
{ error: "Failed to get unread count" },
{ status: 500 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const benefits = await prisma.premiumBenefit.findMany({
where: { active: true },
orderBy: { createdAt: "asc" },
});
const grouped = {
premium: benefits.filter((b) => b.tier === "premium"),
pro: benefits.filter((b) => b.tier === "pro"),
};
return NextResponse.json({ benefits: grouped });
} catch (error) {
console.error("GET /api/premium/benefits error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const isPremium = auth.isPremium || auth.isPro;
const multiplier = isPremium ? 2 : 1;
const reason = isPremium
? "Premium/Pro members earn 2x FLH on marketplace sales"
: "Free members earn 1x FLH. Upgrade to Premium for 2x!";
return NextResponse.json({ multiplier, reason });
} catch (error) {
console.error("GET /api/premium/flh-multiplier error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Decode a base62 referral code back to a userId prefix for lookup
function decodeBase62Lookup(code: string): number {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let hash = 0;
for (const ch of code) {
const idx = chars.indexOf(ch);
if (idx === -1) return -1;
hash = hash * 62 + idx;
}
return hash >>> 0;
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { code } = body;
if (!code || typeof code !== "string") {
return NextResponse.json(
{ error: "Referral code is required" },
{ status: 400 }
);
}
// In a real system, you'd look up the referrer by their stored referral code.
// For simplicity, we encode the userId in the code. We need to find the user
// whose userId produces this code. Since encodeBase62 uses a hash, we
// can't reverse it perfectly. Instead, we iterate active users.
// A production approach: store referral codes in a dedicated table or column.
// For now, we'll iterate through users and find a match.
const users = await prisma.user.findMany({
select: { id: true },
});
// Recreate the encode logic to find the matching user
function encodeForMatch(id: string): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let hash = 0;
for (let i = 0; i < Math.min(id.length, 10); i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
let result = "";
let h = hash;
while (result.length < 6) {
result = chars[h % 62] + result;
h = Math.floor(h / 62);
if (h === 0 && result.length < 6) {
h = Math.floor(Math.random() * 62 ** (6 - result.length));
}
}
return result;
}
let referrerId: string | null = null;
for (const u of users) {
if (encodeForMatch(u.id) === code) {
referrerId = u.id;
break;
}
}
if (!referrerId) {
return NextResponse.json(
{ error: "Invalid referral code" },
{ status: 400 }
);
}
// Save the referral code in a cookie/localStorage so when user registers,
// the newly created user can be linked. For now, we just create a placeholder
// "pending" referral. The actual linking happens when the user registers
// and passes the referral code again (to be paired via the referred user's ID).
// Since we don't have a "referredId" yet, we store a temporary token.
const tempToken = `pending_${referrerId}_${Date.now()}`;
// Check if this referrer already has a pending referral with this temp token
const existing = await prisma.referral.findFirst({
where: { referrerId, status: "pending" },
orderBy: { createdAt: "desc" },
});
// Store in a lightweight way — we'll use the Referral model with a placeholder referredId
// The referredId will be updated when registration completes.
// For production, a ReferralClaimToken model would be better.
const referral = await prisma.referral.create({
data: {
referrerId,
referredId: `pending_${tempToken}`, // placeholder, will be updated on registration
status: "pending",
rewardFlh: 0,
},
});
return NextResponse.json({
success: true,
message: "Referral code applied! You'll get 1000 FLH when you sign up.",
referralId: referral.id,
tempToken,
});
} catch (error) {
console.error("Referral claim error:", error);
return NextResponse.json(
{ error: "Failed to claim referral" },
{ status: 500 }
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
// Encode a userId (UUID/CUID) into a short base62 referral code
function encodeBase62(id: string): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Convert first 10 chars of the id to a numeric hash for a short code
let hash = 0;
for (let i = 0; i < Math.min(id.length, 10); i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0; // unsigned 32-bit
}
let code = "";
while (code.length < 6) {
code = chars[hash % 62] + code;
hash = Math.floor(hash / 62);
if (hash === 0 && code.length < 6) {
// Pad with random chars
hash = Math.floor(Math.random() * 62 ** (6 - code.length));
}
}
return code;
}
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const referralCode = encodeBase62(jwtPayload.userId);
return NextResponse.json({
referralCode,
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falah.app"}/join?ref=${referralCode}`,
});
} catch (error) {
console.error("Referral code error:", error);
return NextResponse.json(
{ error: "Failed to generate referral code" },
{ status: 500 }
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// Same encodeBase62 used in the code route
function encodeBase62(id: string): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let hash = 0;
for (let i = 0; i < Math.min(id.length, 10); i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
let code = "";
while (code.length < 6) {
code = chars[hash % 62] + code;
hash = Math.floor(hash / 62);
if (hash === 0 && code.length < 6) {
hash = Math.floor(Math.random() * 62 ** (6 - code.length));
}
}
return code;
}
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
if (!jwtPayload) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const userId = jwtPayload.userId;
const [totalReferrals, upgradedCount, earnings] = await Promise.all([
prisma.referral.count({
where: { referrerId: userId },
}),
prisma.referral.count({
where: { referrerId: userId, status: "upgraded" },
}),
prisma.referral.aggregate({
where: { referrerId: userId },
_sum: { rewardFlh: true },
}),
]);
const referralCode = encodeBase62(userId);
return NextResponse.json({
totalReferrals,
upgradedCount,
totalEarned: earnings._sum.rewardFlh || 0,
referralCode,
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falah.app"}/join?ref=${referralCode}`,
});
} catch (error) {
console.error("Referral stats error:", error);
return NextResponse.json(
{ error: "Failed to get referral stats" },
{ status: 500 }
);
}
}