changemaker.lite/api/src/modules/media/routes/user-profile.routes.ts
bunker-admin 99a6abab06 Add video card insert feature + MkDocs video hydration + fixes
- 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
2026-02-17 15:42:32 -07:00

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