feat: quiz

This commit is contained in:
gosangam
2026-10-15 19:40:28 +05:30
parent 3f3fa9a720
commit 111485a43e
20 changed files with 2178 additions and 224 deletions
+184
View File
@@ -121,6 +121,8 @@ export type AdminUser = {
name: string;
avatar_url: string | null;
role: string;
/** Staff role key (superadmin/admin/moderator/custom), absent for users. */
admin_role?: string | null;
plan: string;
email_verified_at: string | null;
banned_at: string | null;
@@ -561,3 +563,185 @@ export const vani = {
);
},
};
// ─── Staff, roles and permissions ───────────────────────────────────────────
/** The signed-in staff member's role and effective permissions. */
export type StaffMe = {
role_key: string;
role_name: string;
permissions: string[];
};
/** Held only by superadmins: managing other staff and roles. */
export const MANAGE_STAFF = "admins:manage";
export type Permission = { key: string; group: string; label: string };
export type AdminRole = {
id: string;
key: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
member_count: number;
};
export type StaffMember = {
user_id: string;
handle: string;
name: string;
email?: string | null;
avatar_url?: string | null;
role_id: string;
role_key: string;
role_name: string;
blocked_at?: string | null;
};
export const staff = {
/** Resolves only for staff; a non-staff or blocked account gets a 403. */
me: () => call<StaffMe>("/admin/me").then((r) => r.data),
catalog: () => call<Permission[]>("/admin/permissions").then((r) => r.data),
list: () => call<StaffMember[]>("/admin/staff").then((r) => r.data),
grant: (handle: string, roleId: string) =>
call<StaffMember>("/admin/staff", {
method: "POST",
body: JSON.stringify({ handle, role_id: roleId }),
}).then((r) => r.data),
changeRole: (userId: string, roleId: string) =>
call<StaffMember>(`/admin/staff/${userId}`, {
method: "PATCH",
body: JSON.stringify({ role_id: roleId }),
}).then((r) => r.data),
revoke: (userId: string) =>
call<void>(`/admin/staff/${userId}`, { method: "DELETE" }),
setBlocked: (userId: string, blocked: boolean) =>
call<StaffMember>(`/admin/staff/${userId}/block`, {
method: blocked ? "POST" : "DELETE",
}).then((r) => r.data),
roles: () => call<AdminRole[]>("/admin/roles").then((r) => r.data),
createRole: (input: { name: string; description: string; permissions: string[] }) =>
call<AdminRole>("/admin/roles", { method: "POST", body: JSON.stringify(input) }).then(
(r) => r.data,
),
updateRole: (
id: string,
input: { name: string; description: string; permissions: string[] },
) =>
call<AdminRole>(`/admin/roles/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
}).then((r) => r.data),
deleteRole: (id: string) => call<void>(`/admin/roles/${id}`, { method: "DELETE" }),
};
// ─── Quizzes ────────────────────────────────────────────────────────────────
export type QuizTargetKind = "chapter" | "lecture" | "conversation" | "standalone";
export type QuizOption = { id?: string; ord?: number; body: string; is_correct: boolean };
export type QuizQuestion = {
id?: string;
ord?: number;
prompt: string;
explanation: string;
/** "Select all that apply": one or more options correct. */
multi: boolean;
options: QuizOption[];
};
export type Quiz = {
id: string;
title: string;
description: string;
target_kind: QuizTargetKind;
target_path?: string | null;
target_title: string;
published_at?: string | null;
created_at: string;
updated_at: string;
question_count: number;
attempt_count?: number;
completed_count?: number;
questions?: QuizQuestion[];
};
export type QuizDraft = {
title: string;
description: string;
/** Pasted app link; empty for a stand-alone quiz. */
target_url: string;
/** Optional override for the resolved content title. */
target_title: string;
/** Omitted once a quiz has attempts — its questions are frozen. */
questions?: QuizQuestion[];
};
export type QuizParticipant = {
user_id: string;
handle: string;
name: string;
avatar_url?: string | null;
started_at: string;
completed_at?: string | null;
answered: number;
score: number;
total: number;
total_ms: number;
};
export type QuizQuestionStat = {
id: string;
ord: number;
prompt: string;
multi: boolean;
answered: number;
correct: number;
avg_ms: number;
median_ms: number;
options: (QuizOption & { id: string; picks: number })[];
};
export type QuizResultItem = QuizQuestion & {
chosen_option_ids: string[];
is_correct: boolean;
response_ms?: number | null;
};
export type QuizResult = {
quiz: Quiz;
score: number;
total: number;
started_at: string;
completed_at?: string | null;
total_ms: number;
items: QuizResultItem[];
};
export const quizzes = {
list: () => call<Quiz[]>("/admin/quizzes").then((r) => r.data),
get: (id: string) => call<Quiz>(`/admin/quizzes/${id}`).then((r) => r.data),
create: (draft: QuizDraft) =>
call<Quiz>("/admin/quizzes", { method: "POST", body: JSON.stringify(draft) }).then(
(r) => r.data,
),
update: (id: string, draft: QuizDraft) =>
call<Quiz>(`/admin/quizzes/${id}`, { method: "PUT", body: JSON.stringify(draft) }).then(
(r) => r.data,
),
setPublished: (id: string, on: boolean) =>
call<Quiz>(`/admin/quizzes/${id}/publish`, { method: on ? "POST" : "DELETE" }).then(
(r) => r.data,
),
remove: (id: string) => call<void>(`/admin/quizzes/${id}`, { method: "DELETE" }),
participants: (id: string) =>
call<QuizParticipant[]>(`/admin/quizzes/${id}/participants`).then((r) => r.data),
participant: (id: string, userId: string) =>
call<QuizResult>(`/admin/quizzes/${id}/participants/${userId}`).then((r) => r.data),
responses: (id: string) =>
call<QuizQuestionStat[]>(`/admin/quizzes/${id}/responses`).then((r) => r.data),
};