EventBuzz_Website/src/lib/sessionQuizPollsRtdb.ts

253 lines
7.9 KiB
TypeScript

import { ref, onValue, type Unsubscribe } from "firebase/database";
import { getRtdb } from "./firebaseClient";
export type RtdbQuizPollSettings = {
isLive?: boolean;
startTime?: string;
endTime?: string;
/** always_live | always_sched | manual | quiz_master */
displayMode?: string;
/** draft | active */
status?: string;
timeRestricted?: boolean;
/** Seconds for timed mode */
timeLimit?: number;
showResults?: boolean;
};
/** Normalize status from RTDB; legacy nodes without status are treated as active. */
function settingsStatusActive(settings: RtdbQuizPollSettings): boolean {
const raw = settings.status?.trim().toLowerCase();
if (raw == null || raw === "") return true;
return raw === "active";
}
export function settingsDisplayMode(settings: RtdbQuizPollSettings): string {
const m = settings.displayMode?.trim().toLowerCase();
if (!m) return "manual";
return m;
}
/**
* First visible quiz/poll that is audience-controlled via RTDB (`quiz_master` / `manual` while live).
* `visibleQuizPolls` should be the output of {@link filterVisibleQuizPolls}.
*/
export function firstQuizMasterOrManualVisibleId(
visibleQuizPolls: RtdbQuizPollListItem[],
): string | null {
for (const item of visibleQuizPolls) {
const mode = settingsDisplayMode(item.settings);
if (mode === "quiz_master" || mode === "manual") {
return item._id;
}
}
return null;
}
/**
* Session list & take-page visibility (aligned with dashboard display modes).
*
* - always_sched: status active, now in [startTime, endTime] (inclusive)
* - always_live: status active
* - quiz_master: status active and isLive
* - manual: status active and isLive
*/
export function isQuizPollVisibleForAudience(settings: RtdbQuizPollSettings, now: Date = new Date()): boolean {
if (!settingsStatusActive(settings)) return false;
const mode = settingsDisplayMode(settings);
const isLive = Boolean(settings.isLive);
if (mode === "always_live") return true;
if (mode === "always_sched") {
const start = settings.startTime != null ? Date.parse(settings.startTime) : NaN;
const end = settings.endTime != null ? Date.parse(settings.endTime) : NaN;
const t = now.getTime();
if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
return t >= start && t <= end;
}
if (mode === "quiz_master" || mode === "manual") {
return isLive;
}
return false;
}
export type RtdbQuizPollOption = {
_id: string;
text: string;
isCorrect: boolean;
};
export type RtdbQuizPollQuestion = {
_id: string;
question: string;
type: string;
options: RtdbQuizPollOption[];
points?: number;
};
export type RtdbQuizPollListItem = {
_id: string;
title: string;
type: string;
isLive: boolean;
settings: RtdbQuizPollSettings;
};
export type RtdbQuizPollDetail = RtdbQuizPollListItem & {
questions: RtdbQuizPollQuestion[];
};
function readSettings(raw: Record<string, unknown>): RtdbQuizPollSettings {
const s = raw.settings;
if (!s || typeof s !== "object" || Array.isArray(s)) return {};
const o = s as Record<string, unknown>;
const isLive =
typeof o.isLive === "boolean"
? o.isLive
: typeof o.isActive === "boolean"
? o.isActive
: false;
const startTime = o.startTime != null ? String(o.startTime) : undefined;
const endTime = o.endTime != null ? String(o.endTime) : undefined;
const displayMode = o.displayMode != null ? String(o.displayMode).trim() : undefined;
const status = o.status != null ? String(o.status).trim() : undefined;
const timeRestricted = o.timeRestricted === true;
const timeLimitRaw = o.timeLimit;
const timeLimit =
typeof timeLimitRaw === "number" && Number.isFinite(timeLimitRaw) ? Math.max(0, Math.floor(timeLimitRaw)) : 0;
const showResults = o.showResults !== false;
return { isLive, startTime, endTime, displayMode, status, timeRestricted, timeLimit, showResults };
}
function mapOptions(raw: unknown): RtdbQuizPollOption[] {
if (!Array.isArray(raw)) return [];
return raw.map((opt, j) => {
if (!opt || typeof opt !== "object") {
return { _id: `opt-${j}`, text: "", isCorrect: false };
}
const o = opt as Record<string, unknown>;
const id = o._id != null && String(o._id).trim() !== "" ? String(o._id).trim() : `opt-${j}`;
return {
_id: id,
text: o.text != null ? String(o.text) : "",
isCorrect: Boolean(o.isCorrect),
};
});
}
function mapQuestions(raw: unknown): RtdbQuizPollQuestion[] {
if (!Array.isArray(raw)) return [];
return raw.map((q, i) => {
if (!q || typeof q !== "object") {
return { _id: `q-${i}`, question: "", type: "single", options: [] };
}
const o = q as Record<string, unknown>;
const id = o._id != null && String(o._id).trim() !== "" ? String(o._id).trim() : `q-${i}`;
const qt = o.type != null ? String(o.type) : "single";
return {
_id: id,
question: o.question != null ? String(o.question) : "",
type: qt,
options: mapOptions(o.options),
points: typeof o.points === "number" ? o.points : undefined,
};
});
}
export function rtdbSnapshotToQuizPolls(val: unknown): RtdbQuizPollListItem[] {
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
const data = val as Record<string, Record<string, unknown>>;
return Object.entries(data)
.map(([key, raw]) => {
if (!raw || typeof raw !== "object") return null;
const v = raw as Record<string, unknown>;
const settings = readSettings(v);
const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || key;
return {
_id: id,
title: v.title != null ? String(v.title) : "",
type: v.type != null ? String(v.type) : "poll",
isLive: Boolean(settings.isLive),
settings,
} satisfies RtdbQuizPollListItem;
})
.filter((x): x is RtdbQuizPollListItem => x != null)
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }));
}
export function filterVisibleQuizPolls(
items: RtdbQuizPollListItem[],
now: Date = new Date(),
): RtdbQuizPollListItem[] {
return items.filter((x) => isQuizPollVisibleForAudience(x.settings, now));
}
export function rtdbValToQuizPollDetail(val: unknown, fallbackId: string): RtdbQuizPollDetail | null {
if (!val || typeof val !== "object" || Array.isArray(val)) return null;
const v = val as Record<string, unknown>;
const settings = readSettings(v);
const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || fallbackId;
return {
_id: id,
title: v.title != null ? String(v.title) : "",
type: v.type != null ? String(v.type) : "poll",
isLive: Boolean(settings.isLive),
settings,
questions: mapQuestions(v.questions),
};
}
export function subscribeSessionQuizPolls(
sessionId: string,
onData: (items: RtdbQuizPollListItem[]) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/quizPolls`);
return onValue(
r,
(snap) => {
try {
onData(rtdbSnapshotToQuizPolls(snap.val()));
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}
export function subscribeSessionQuizPollDetail(
sessionId: string,
quizPollId: string,
onData: (detail: RtdbQuizPollDetail | null) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/quizPolls/${quizPollId}`);
return onValue(
r,
(snap) => {
try {
const d = rtdbValToQuizPollDetail(snap.val(), quizPollId);
onData(d);
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}