import { prisma } from "../database/client"; import { hashPassword, verifyPassword } from "../security/crypto"; import { AppError } from "../security/errors"; /** Vaste avatar-set (client toont emoji/tekening per id). */ export const PROFILE_AVATARS = [ { id: "fox", label: "Vos" }, { id: "panda", label: "Panda" }, { id: "robot", label: "Robot" }, { id: "alien", label: "Alien" }, { id: "ninja", label: "Ninja" }, { id: "pirate", label: "Piraat" }, { id: "wizard", label: "Tovenaar" }, { id: "dino", label: "Dino" }, { id: "cat", label: "Kat" }, { id: "rocket", label: "Raket" }, ] as const; export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"]; const MAX_PROFILES = 8; const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000; const PIN_MAX_ATTEMPTS = 5; /** In-memory PIN rate limit: profileId:deviceId → attempts */ const pinAttempts = new Map(); function isAvatarId(id: string): id is ProfileAvatarId { return PROFILE_AVATARS.some((a) => a.id === id); } function normalizePin(pin: string | null | undefined): string | null { if (pin == null || pin === "") return null; const digits = String(pin).replace(/\D/g, ""); if (digits.length !== 4) { throw new AppError("INVALID_REQUEST", "PIN moet precies 4 cijfers zijn", 400); } return digits; } function publicProfile(row: { id: string; name: string; avatarId: string; isOwner: boolean; isKids: boolean; pinHash: string | null; }) { return { id: row.id, name: row.name, avatarId: row.avatarId, isOwner: row.isOwner, isKids: row.isKids, hasPin: !!row.pinHash, }; } async function assertCanManage( viewerId: string, activeProfileId: string | null ) { if (!activeProfileId) { throw new AppError( "FORBIDDEN", "Selecteer eerst het hoofdprofiel om te beheren", 403 ); } const me = await prisma.viewerProfile.findFirst({ where: { id: activeProfileId, viewerUserId: viewerId }, }); if (!me?.isOwner) { throw new AppError("FORBIDDEN", "Alleen de hoofdgebruiker mag dit", 403); } } export async function ensureOwnerProfile( viewerId: string, preferredName?: string | null ) { const existing = await prisma.viewerProfile.findFirst({ where: { viewerUserId: viewerId, isOwner: true }, }); if (existing) return existing; const name = preferredName?.trim() || (await prisma.viewerUser.findUnique({ where: { id: viewerId } }))?.name?.trim() || "Hoofdprofiel"; return prisma.viewerProfile.create({ data: { viewerUserId: viewerId, name: name.slice(0, 32), avatarId: "fox", isOwner: true, isKids: false, }, }); } export class ViewerProfileService { avatars() { return PROFILE_AVATARS.map((a) => ({ ...a })); } async list(viewerId: string) { await ensureOwnerProfile(viewerId); const rows = await prisma.viewerProfile.findMany({ where: { viewerUserId: viewerId }, orderBy: [{ isOwner: "desc" }, { createdAt: "asc" }], }); return rows.map(publicProfile); } async create( viewerId: string, activeProfileId: string | null, input: { name?: string; avatarId?: string; isKids?: boolean; pin?: string | null; } ) { await assertCanManage(viewerId, activeProfileId); const count = await prisma.viewerProfile.count({ where: { viewerUserId: viewerId } }); if (count >= MAX_PROFILES) { throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400); } const name = (input.name || "").trim().slice(0, 32); if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400); const avatarId = input.avatarId || "fox"; if (!isAvatarId(avatarId)) { throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400); } const pin = normalizePin(input.pin); const isKids = input.isKids === true; try { const row = await prisma.viewerProfile.create({ data: { viewerUserId: viewerId, name, avatarId, isOwner: false, isKids, pinHash: pin ? await hashPassword(pin) : null, }, }); return publicProfile(row); } catch { throw new AppError("CONFLICT", "Profielnaam bestaat al", 409); } } async update( viewerId: string, activeProfileId: string | null, profileId: string, input: { name?: string; avatarId?: string; isKids?: boolean; pin?: string | null; clearPin?: boolean; } ) { const target = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); const active = activeProfileId ? await prisma.viewerProfile.findFirst({ where: { id: activeProfileId, viewerUserId: viewerId }, }) : null; const isSelf = activeProfileId === profileId; const isOwnerActing = active?.isOwner === true; if (!isOwnerActing && !isSelf) { throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403); } // Alleen owner mag kids-flag of andermans PIN wijzigen if (!isOwnerActing && (input.isKids !== undefined || input.pin !== undefined || input.clearPin)) { if (!isSelf || input.isKids !== undefined) { throw new AppError("FORBIDDEN", "Alleen de hoofdgebruiker mag dit", 403); } } const data: { name?: string; avatarId?: string; isKids?: boolean; pinHash?: string | null; } = {}; if (input.name !== undefined) { const name = input.name.trim().slice(0, 32); if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400); data.name = name; } if (input.avatarId !== undefined) { if (!isAvatarId(input.avatarId)) { throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400); } data.avatarId = input.avatarId; } if (input.isKids !== undefined && isOwnerActing) { // Owner-profiel mag geen kids worden if (target.isOwner && input.isKids) { throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400); } data.isKids = input.isKids; } if (input.clearPin === true && (isOwnerActing || isSelf)) { data.pinHash = null; } else if (input.pin !== undefined && (isOwnerActing || isSelf)) { const pin = normalizePin(input.pin); data.pinHash = pin ? await hashPassword(pin) : null; } try { const row = await prisma.viewerProfile.update({ where: { id: profileId }, data, }); return publicProfile(row); } catch { throw new AppError("CONFLICT", "Profielnaam bestaat al", 409); } } async remove(viewerId: string, activeProfileId: string | null, profileId: string) { await assertCanManage(viewerId, activeProfileId); const target = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); if (target.isOwner) { throw new AppError("FORBIDDEN", "Hoofdprofiel kan niet verwijderd worden", 403); } if (activeProfileId === profileId) { throw new AppError("INVALID_REQUEST", "Je kunt het actieve profiel niet verwijderen", 400); } await prisma.viewerDevice.updateMany({ where: { activeProfileId: profileId }, data: { activeProfileId: null }, }); await prisma.viewerProfile.delete({ where: { id: profileId } }); return { ok: true }; } async select( viewerId: string, deviceId: string, profileId: string, pin?: string | null ) { const profile = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!profile) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); if (profile.pinHash) { const key = `${profileId}:${deviceId}`; const now = Date.now(); let bucket = pinAttempts.get(key); if (!bucket || bucket.resetAt < now) { bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS }; pinAttempts.set(key, bucket); } if (bucket.count >= PIN_MAX_ATTEMPTS) { throw new AppError( "RATE_LIMITED", "Te veel PIN-pogingen. Probeer later opnieuw.", 429 ); } const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false; if (!ok) { bucket.count += 1; throw new AppError("FORBIDDEN", "Onjuiste PIN", 403); } pinAttempts.delete(key); } await prisma.viewerDevice.update({ where: { id: deviceId }, data: { activeProfileId: profile.id }, }); return { profile: publicProfile(profile) }; } async clearActive(deviceId: string) { await prisma.viewerDevice.update({ where: { id: deviceId }, data: { activeProfileId: null }, }); return { ok: true }; } }