a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
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>
56 lines
1.8 KiB
TypeScript
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;
|
|
}
|