- New video card block for GrapesJS landing pages, email templates, MkDocs export, and documentation editor Insert dropdown - Shared HTML generators in admin/src/utils/videoCardHtml.ts - MkDocs video-player.js hydrates .video-card-block elements: thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link - Media API CORS: auto-add MkDocs + docs subdomain origins - env_config_hook.py: smart Docker hostname detection, ADMIN_PORT resolution, pass env vars to MkDocs container - Gallery URL uses /gallery?expanded=ID format - VideoPickerModal: fix double /api prefix and Docker hostname thumbs - Seed: default-video-card PageBlock - Remove V1 legacy code (influence/, map/) Bunker Admin
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import { FastifyInstance } from 'fastify';
|
|
import bcrypt from 'bcryptjs';
|
|
import { prisma } from '../../../config/database';
|
|
import { authenticate } from '../middleware/auth';
|
|
|
|
interface UpdateSettingsBody {
|
|
showOnlineStatus?: boolean;
|
|
showCurrentlyWatching?: boolean;
|
|
showInFriendActivity?: boolean;
|
|
anonymizePublicComments?: boolean;
|
|
hidePublicReactions?: boolean;
|
|
hidePublicFinishes?: boolean;
|
|
allowFriendRequests?: boolean;
|
|
closeFriendsOnlyWatching?: boolean;
|
|
}
|
|
|
|
interface UpdateProfileBody {
|
|
name?: string;
|
|
}
|
|
|
|
interface ChangePasswordBody {
|
|
currentPassword: string;
|
|
newPassword: string;
|
|
}
|
|
|
|
interface WatchHistoryQuery {
|
|
limit?: string;
|
|
offset?: string;
|
|
}
|
|
|
|
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{12,}$/;
|
|
|
|
export async function userProfileRoutes(fastify: FastifyInstance) {
|
|
// ─── Stats ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /me/stats
|
|
* Returns UserStats (upsert if missing), recent daily activity, achievement count
|
|
*/
|
|
fastify.get(
|
|
'/me/stats',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
|
|
// Upsert UserStats (create with defaults if missing)
|
|
const stats = await prisma.userStats.upsert({
|
|
where: { userId },
|
|
create: { userId },
|
|
update: {},
|
|
});
|
|
|
|
// Last 30 days of daily activity
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
const dateStr = thirtyDaysAgo.toISOString().split('T')[0];
|
|
|
|
const dailyActivity = await prisma.userDailyActivity.findMany({
|
|
where: {
|
|
userId,
|
|
activityDate: { gte: dateStr },
|
|
},
|
|
orderBy: { activityDate: 'asc' },
|
|
});
|
|
|
|
// Achievement count
|
|
const achievementCount = await prisma.userAchievement.count({
|
|
where: { userId },
|
|
});
|
|
|
|
return reply.send({
|
|
stats,
|
|
dailyActivity,
|
|
achievementCount,
|
|
});
|
|
}
|
|
);
|
|
|
|
/**
|
|
* GET /me/watch-history
|
|
* Paginated recent VideoView records with video info
|
|
*/
|
|
fastify.get<{ Querystring: WatchHistoryQuery }>(
|
|
'/me/watch-history',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
|
const offset = parseInt(request.query.offset || '0', 10);
|
|
|
|
const [views, total] = await Promise.all([
|
|
prisma.videoView.findMany({
|
|
where: { userId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: limit,
|
|
skip: offset,
|
|
select: {
|
|
id: true,
|
|
watchTimeSeconds: true,
|
|
completed: true,
|
|
createdAt: true,
|
|
video: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
filename: true,
|
|
thumbnailPath: true,
|
|
durationSeconds: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
prisma.videoView.count({ where: { userId } }),
|
|
]);
|
|
|
|
return reply.send({ views, total, limit, offset });
|
|
}
|
|
);
|
|
|
|
/**
|
|
* POST /me/stats/recalculate
|
|
* Recompute UserStats from raw data
|
|
*/
|
|
fastify.post(
|
|
'/me/stats/recalculate',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
|
|
// Aggregate from raw tables
|
|
const [viewAgg, commentCount, reactionCount, finishCount, dailyActivity] =
|
|
await Promise.all([
|
|
prisma.videoView.aggregate({
|
|
where: { userId },
|
|
_sum: { watchTimeSeconds: true },
|
|
_count: true,
|
|
}),
|
|
prisma.comment.count({ where: { userId } }),
|
|
prisma.videoReaction.count({ where: { userId } }),
|
|
prisma.userFinish.count({ where: { userId } }),
|
|
prisma.userDailyActivity.findMany({
|
|
where: { userId },
|
|
orderBy: { activityDate: 'desc' },
|
|
select: { activityDate: true, firstActivityHour: true },
|
|
}),
|
|
]);
|
|
|
|
// Calculate streaks from daily activity
|
|
let currentStreak = 0;
|
|
let longestStreak = 0;
|
|
let tempStreak = 0;
|
|
let nightOwlCount = 0;
|
|
let earlyBirdCount = 0;
|
|
|
|
// Sort descending (most recent first) for streak calculation
|
|
const sortedDates = dailyActivity
|
|
.map((d) => d.activityDate)
|
|
.sort()
|
|
.reverse();
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
for (let i = 0; i < sortedDates.length; i++) {
|
|
const date = new Date(sortedDates[i]);
|
|
const expected = new Date(today);
|
|
expected.setDate(expected.getDate() - i);
|
|
|
|
if (date.toISOString().split('T')[0] === expected.toISOString().split('T')[0]) {
|
|
tempStreak++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
currentStreak = tempStreak;
|
|
|
|
// Calculate longest streak (ascending order)
|
|
const ascending = [...sortedDates].reverse();
|
|
tempStreak = 1;
|
|
for (let i = 1; i < ascending.length; i++) {
|
|
const prev = new Date(ascending[i - 1]);
|
|
const curr = new Date(ascending[i]);
|
|
const diffMs = curr.getTime() - prev.getTime();
|
|
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 1) {
|
|
tempStreak++;
|
|
} else {
|
|
longestStreak = Math.max(longestStreak, tempStreak);
|
|
tempStreak = 1;
|
|
}
|
|
}
|
|
longestStreak = Math.max(longestStreak, tempStreak);
|
|
if (ascending.length === 0) longestStreak = 0;
|
|
|
|
// Night owl (activity hour >= 22 or < 5) vs Early bird (5-9)
|
|
for (const d of dailyActivity) {
|
|
if (d.firstActivityHour != null) {
|
|
if (d.firstActivityHour >= 22 || d.firstActivityHour < 5) {
|
|
nightOwlCount++;
|
|
} else if (d.firstActivityHour >= 5 && d.firstActivityHour < 10) {
|
|
earlyBirdCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Longest single session from VideoView
|
|
const longestSession = await prisma.videoView.aggregate({
|
|
where: { userId },
|
|
_max: { watchTimeSeconds: true },
|
|
});
|
|
|
|
const stats = await prisma.userStats.upsert({
|
|
where: { userId },
|
|
create: {
|
|
userId,
|
|
totalWatchTimeSeconds: viewAgg._sum.watchTimeSeconds || 0,
|
|
totalVideosWatched: viewAgg._count,
|
|
totalCommentsMade: commentCount,
|
|
totalUpvotesGiven: reactionCount,
|
|
totalFinishes: finishCount,
|
|
currentDayStreak: currentStreak,
|
|
longestDayStreak: longestStreak,
|
|
nightOwlCount,
|
|
earlyBirdCount,
|
|
longestSingleSession: longestSession._max.watchTimeSeconds || 0,
|
|
lastActiveDate: today,
|
|
updatedAt: new Date(),
|
|
},
|
|
update: {
|
|
totalWatchTimeSeconds: viewAgg._sum.watchTimeSeconds || 0,
|
|
totalVideosWatched: viewAgg._count,
|
|
totalCommentsMade: commentCount,
|
|
totalUpvotesGiven: reactionCount,
|
|
totalFinishes: finishCount,
|
|
currentDayStreak: currentStreak,
|
|
longestDayStreak: longestStreak,
|
|
nightOwlCount,
|
|
earlyBirdCount,
|
|
longestSingleSession: longestSession._max.watchTimeSeconds || 0,
|
|
lastActiveDate: today,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return reply.send({ stats, recalculated: true });
|
|
}
|
|
);
|
|
|
|
// ─── Settings ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /me/settings
|
|
* Returns PrivacySettings (upsert defaults if missing) + user profile
|
|
*/
|
|
fastify.get(
|
|
'/me/settings',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
|
|
const [privacy, user] = await Promise.all([
|
|
prisma.privacySettings.upsert({
|
|
where: { userId },
|
|
create: { userId },
|
|
update: {},
|
|
}),
|
|
prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { name: true, email: true },
|
|
}),
|
|
]);
|
|
|
|
return reply.send({ privacy, profile: user });
|
|
}
|
|
);
|
|
|
|
/**
|
|
* PUT /me/settings
|
|
* Update PrivacySettings boolean toggles
|
|
*/
|
|
fastify.put<{ Body: UpdateSettingsBody }>(
|
|
'/me/settings',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
const body = request.body;
|
|
|
|
// Only allow known boolean fields
|
|
const allowedFields: (keyof UpdateSettingsBody)[] = [
|
|
'showOnlineStatus',
|
|
'showCurrentlyWatching',
|
|
'showInFriendActivity',
|
|
'anonymizePublicComments',
|
|
'hidePublicReactions',
|
|
'hidePublicFinishes',
|
|
'allowFriendRequests',
|
|
'closeFriendsOnlyWatching',
|
|
];
|
|
|
|
const updateData: Record<string, boolean> = {};
|
|
for (const field of allowedFields) {
|
|
if (typeof body[field] === 'boolean') {
|
|
updateData[field] = body[field] as boolean;
|
|
}
|
|
}
|
|
|
|
const privacy = await prisma.privacySettings.upsert({
|
|
where: { userId },
|
|
create: { userId, ...updateData, updatedAt: new Date() },
|
|
update: { ...updateData, updatedAt: new Date() },
|
|
});
|
|
|
|
return reply.send({ privacy });
|
|
}
|
|
);
|
|
|
|
/**
|
|
* PUT /me/profile
|
|
* Update user name (email is read-only)
|
|
*/
|
|
fastify.put<{ Body: UpdateProfileBody }>(
|
|
'/me/profile',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
const { name } = request.body;
|
|
|
|
if (name !== undefined && (typeof name !== 'string' || name.length > 100)) {
|
|
return reply.code(400).send({ message: 'Name must be a string under 100 characters' });
|
|
}
|
|
|
|
const user = await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { name: name?.trim() || null },
|
|
select: { name: true, email: true },
|
|
});
|
|
|
|
return reply.send({ profile: user });
|
|
}
|
|
);
|
|
|
|
/**
|
|
* PUT /me/password
|
|
* Change password (requires current password verification)
|
|
*/
|
|
fastify.put<{ Body: ChangePasswordBody }>(
|
|
'/me/password',
|
|
{ preHandler: [authenticate] },
|
|
async (request, reply) => {
|
|
const userId = request.user!.id;
|
|
const { currentPassword, newPassword } = request.body;
|
|
|
|
if (!currentPassword || !newPassword) {
|
|
return reply
|
|
.code(400)
|
|
.send({ message: 'Current password and new password are required' });
|
|
}
|
|
|
|
// Validate new password meets policy
|
|
if (!PASSWORD_REGEX.test(newPassword)) {
|
|
return reply.code(400).send({
|
|
message:
|
|
'Password must be at least 12 characters with uppercase, lowercase, and a digit',
|
|
});
|
|
}
|
|
|
|
// Fetch current password hash
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { password: true },
|
|
});
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ message: 'User not found' });
|
|
}
|
|
|
|
// Verify current password
|
|
const isValid = await bcrypt.compare(currentPassword, user.password);
|
|
if (!isValid) {
|
|
return reply.code(401).send({ message: 'Current password is incorrect' });
|
|
}
|
|
|
|
// Hash and save new password
|
|
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { password: hashedPassword },
|
|
});
|
|
|
|
return reply.send({ message: 'Password updated successfully' });
|
|
}
|
|
);
|
|
}
|