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;
}