Files
Vault/lib/account-status.ts
twotalesanimation a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Fix watch tracking, video unlocking and access-control gaps
Watch progress and unlocking:
- Player samples the playhead and reports the ranges actually played,
  so skipped sections earn no credit and early plays are not lost
- Server merges ranges into compact segment rows; progress never regresses
- Single 80% unlock rule enforced server-side (lib/watch-progress.ts);
  the Unlock button and automatic unlock now agree
- Lock state and progress bar update live from each progress save
- Playlist is derived from the video, so unlocks work for shared
  playlists and for links from watch history and liked videos
- First/previous video decided by playlist position, not index numbers

Access control:
- Video API and HLS/MP4 routes require a signed-in viewer who is
  enrolled and has the video unlocked; thumbnails require sign-in
- Enrolment sync reads course codes from the allow-list, not the request
- /api/admin/meta is admin-only
- Account status, role and levels are re-checked from the database
  during a session, so deactivated students lose access within a minute

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 16:58:07 +02:00

56 lines
1.8 KiB
TypeScript

// lib/account-status.ts
// Whether an account may currently use the platform, checked against the
// database rather than whatever was true when the user signed in.
import { prisma } from './prisma';
import { normalizeEmail } from './normalize-email';
export type AccountStatus = {
/** False once a student is removed from, or deactivated on, the allow-list. */
allowed: boolean;
role: string;
/** Comma-separated course codes from the allow-list. */
levels: string;
};
// Sessions are checked on every request, so results are held briefly. A change
// to a student's access therefore takes effect within this window.
const TTL_MS = 60_000;
const MAX_ENTRIES = 5000;
const cache = new Map<string, { expires: number; status: AccountStatus }>();
export async function getAccountStatus(
userId: string,
opts: { fresh?: boolean } = {}
): Promise<AccountStatus> {
const hit = cache.get(userId);
if (!opts.fresh && hit && hit.expires > Date.now()) return hit.status;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, role: true },
});
let status: AccountStatus = { allowed: false, role: 'user', levels: '' };
if (user) {
const role = user.role ?? 'user';
const email = normalizeEmail(user.email);
const student = email
? await prisma.allowedStudent.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { levels: true, active: true },
})
: null;
const isAdmin = role === 'admin' || role === 'superadmin';
status = {
allowed: isAdmin || !!student?.active,
role,
levels: student?.levels ?? '',
};
}
if (cache.size >= MAX_ENTRIES) cache.clear();
cache.set(userId, { expires: Date.now() + TTL_MS, status });
return status;
}