feat: quiz
This commit is contained in:
+184
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user