From 1014f4a72f16489c69ee84bed82974fc42f8293c Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 5 Jun 2026 05:44:09 -0700 Subject: [PATCH] feat(admin): notification details, question type library, and schema UX Wire GET /notifications/:id for topbar and notifications page detail views, harden notification WebSocket lifecycle, paginate question type and app version lists from API, and expand dynamic question type schema labels and slot editing. Co-authored-by: Cursor --- src/api/app-versions.api.ts | 3 +- src/api/notifications.api.ts | 116 ++++- src/api/questionTypeDefinitions.api.ts | 62 ++- .../DynamicSchemaSlotField.tsx | 99 +++-- .../PracticeQuestionEditorFields.tsx | 4 +- .../NotificationDetailDialog.tsx | 169 +++++++ .../topbar/NotificationDropdown.tsx | 279 ++++++------ src/hooks/useNotifications.ts | 61 ++- src/lib/learnEnglishDefinitionQuestion.ts | 3 +- src/lib/notificationDisplay.ts | 101 +++++ src/lib/schemaSlotLabel.ts | 41 ++ src/pages/SettingsPage.tsx | 60 --- .../content-management/AddPracticeFlow.tsx | 2 +- .../content-management/AddQuestionPage.tsx | 4 +- .../CreateQuestionTypeFlow.tsx | 13 +- .../QuestionTypeLibraryPage.tsx | 413 +++++++++++++++--- .../components/QuestionTypeCard.tsx | 9 +- .../practice-steps/QuestionsStep.tsx | 6 +- .../QuestionTypeConfigStep.tsx | 169 +++++-- .../QuestionTypeReviewPublishStep.tsx | 7 +- .../QuestionTypeValidatePreviewStep.tsx | 12 +- .../SchemaBuilderSection.tsx | 36 +- .../SchemaSlotLabelsPanel.tsx | 137 ++++++ .../question-type-steps/componentKindUi.ts | 59 ++- .../lib/questionTypeDefinitionValidation.ts | 16 +- src/pages/notifications/NotificationsPage.tsx | 213 +++------ src/pages/settings/AppVersionsTab.tsx | 4 +- src/types/notification.types.ts | 1 + 28 files changed, 1508 insertions(+), 591 deletions(-) create mode 100644 src/components/notifications/NotificationDetailDialog.tsx create mode 100644 src/lib/notificationDisplay.ts create mode 100644 src/lib/schemaSlotLabel.ts create mode 100644 src/pages/content-management/components/question-type-steps/SchemaSlotLabelsPanel.tsx diff --git a/src/api/app-versions.api.ts b/src/api/app-versions.api.ts index 64dcd9b..06cd749 100644 --- a/src/api/app-versions.api.ts +++ b/src/api/app-versions.api.ts @@ -1,4 +1,5 @@ import http from "./http" +import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination" import type { AppVersion, AppVersionMutationResponse, @@ -77,7 +78,7 @@ export type GetAppVersionsParams = { } export const getAppVersions = (params: GetAppVersionsParams = {}) => { - const limit = params.limit ?? 20 + const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE const offset = params.offset ?? 0 return http .get("/admin/app-versions", { params: { limit, offset } }) diff --git a/src/api/notifications.api.ts b/src/api/notifications.api.ts index bc59789..4eaeabc 100644 --- a/src/api/notifications.api.ts +++ b/src/api/notifications.api.ts @@ -1,35 +1,125 @@ -import http from "./http"; -import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types"; +import http from "./http" +import type { + GetNotificationsResponse, + Notification, + UnreadCountResponse, +} from "../types/notification.types" + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function unwrapEnvelopeData(body: unknown): unknown { + if (!isRecord(body)) return body + if ("data" in body || "Data" in body) { + return body.data ?? body.Data + } + return body +} + +function normalizePayload(raw: unknown): Notification["payload"] { + if (!isRecord(raw)) { + return { tags: null } + } + const tags = Array.isArray(raw.tags) + ? raw.tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0) + : null + return { + headline: raw.headline != null ? String(raw.headline) : undefined, + title: raw.title != null ? String(raw.title) : undefined, + message: raw.message != null ? String(raw.message) : undefined, + body: raw.body != null ? String(raw.body) : undefined, + tags, + } +} + +export function normalizeNotification(raw: unknown): Notification | null { + if (!isRecord(raw)) return null + const id = String(raw.id ?? "") + if (!id) return null + + return { + id, + recipient_id: Number(raw.recipient_id ?? 0), + receiver_type: raw.receiver_type != null ? String(raw.receiver_type) : undefined, + type: String(raw.type ?? ""), + level: String(raw.level ?? ""), + error_severity: String(raw.error_severity ?? ""), + reciever: String(raw.reciever ?? ""), + is_read: Boolean(raw.is_read), + delivery_status: String(raw.delivery_status ?? ""), + delivery_channel: String(raw.delivery_channel ?? ""), + payload: normalizePayload(raw.payload), + timestamp: String(raw.timestamp ?? ""), + expires: String(raw.expires ?? ""), + image: String(raw.image ?? ""), + } +} + +function parseNotificationsListData(body: unknown, limit: number, offset: number): GetNotificationsResponse { + const inner = unwrapEnvelopeData(body) + if (!isRecord(inner)) { + return { notifications: [], total_count: 0, limit, offset } + } + + const rows = Array.isArray(inner.notifications) ? inner.notifications : [] + const notifications = rows + .map(normalizeNotification) + .filter((n): n is Notification => n !== null) + + return { + notifications, + total_count: Number(inner.total_count ?? notifications.length), + limit: Number(inner.limit ?? limit), + offset: Number(inner.offset ?? offset), + } +} + +function parseUnreadCount(body: unknown): UnreadCountResponse { + const inner = unwrapEnvelopeData(body) + if (!isRecord(inner)) return { unread: 0 } + return { unread: Number(inner.unread ?? 0) } +} export const getNotifications = (limit = 10, offset = 0) => - http.get("/notifications", { - params: { limit, offset }, - }); + http.get("/notifications", { params: { limit, offset } }).then((res) => ({ + ...res, + data: parseNotificationsListData(res.data, limit, offset), + })) + +export const getNotificationById = (id: string) => + http.get(`/notifications/${id}`).then((res) => ({ + ...res, + data: normalizeNotification(unwrapEnvelopeData(res.data)), + })) export const getUnreadCount = () => - http.get("/notifications/unread"); + http.get("/notifications/unread").then((res) => ({ + ...res, + data: parseUnreadCount(res.data), + })) export const markAsRead = (id: string) => - http.patch(`/notifications/${id}/read`); + http.patch(`/notifications/${id}/read`) export const markAsUnread = (id: string) => - http.patch(`/notifications/${id}/unread`); + http.patch(`/notifications/${id}/unread`) export const markAllRead = () => - http.post("/notifications/mark-all-read"); + http.post("/notifications/mark-all-read") export const markAllUnread = () => - http.post("/notifications/mark-all-unread"); + http.post("/notifications/mark-all-unread") export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) => - http.post("/notifications/bulk-sms", data); + http.post("/notifications/bulk-sms", data) export const sendBulkEmail = (formData: FormData) => http.post("/notifications/bulk-email", formData, { headers: { "Content-Type": "multipart/form-data" }, - }); + }) export const sendBulkPush = (formData: FormData) => http.post("/notifications/bulk-push", formData, { headers: { "Content-Type": "multipart/form-data" }, - }); + }) diff --git a/src/api/questionTypeDefinitions.api.ts b/src/api/questionTypeDefinitions.api.ts index 5c3bc00..699db6c 100644 --- a/src/api/questionTypeDefinitions.api.ts +++ b/src/api/questionTypeDefinitions.api.ts @@ -261,6 +261,40 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u /** @deprecated use extractDefinitionMutationId */ export const extractCreatedDefinitionId = extractDefinitionMutationId +export interface QuestionTypeDefinitionsListParams { + include_system?: boolean + status?: string + limit?: number + offset?: number +} + +export interface QuestionTypeDefinitionsListResult { + definitions: QuestionTypeDefinition[] + total_count?: number +} + +function parseListTotalCount(body: unknown): number | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined + const o = body as Record + + const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount) + if (Number.isFinite(direct) && direct >= 0) return direct + + const meta = o.metadata ?? o.Metadata + if (meta && typeof meta === "object" && !Array.isArray(meta)) { + const m = meta as Record + const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount) + if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta + } + + const data = o.data ?? o.Data + if (data && typeof data === "object" && !Array.isArray(data)) { + return parseListTotalCount(data) + } + + return undefined +} + export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] { if (!payload) return [] if (Array.isArray(payload)) { @@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] } if (typeof payload === "object" && payload !== null) { const o = payload as Record - const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions + const inner = + o.question_type_definitions ?? + o.QuestionTypeDefinitions ?? + o.definitions ?? + o.items ?? + o.rows ?? + o.Definitions if (Array.isArray(inner)) return parseDefinitionsList(inner) - if (inner && typeof inner === "object") return parseDefinitionsList(inner) + if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner) const data = o.data ?? o.Data if (Array.isArray(data)) return parseDefinitionsList(data) - if (data && typeof data === "object") { - const single = normalizeTypeDefinitionFromApi(data) - return single ? [single] : [] - } + if (data && typeof data === "object") return parseDefinitionsList(data) const single = normalizeTypeDefinitionFromApi(payload) return single ? [single] : [] } return [] } -export async function getQuestionTypeDefinitions(params?: { - include_system?: boolean - status?: string - limit?: number - offset?: number -}) { +export async function getQuestionTypeDefinitions( + params?: QuestionTypeDefinitionsListParams, +): Promise { const res = await http.get>("/questions/type-definitions", { params }) const raw = unwrapApiPayload(res) ?? res.data - return parseDefinitionsList(raw) + const definitions = parseDefinitionsList(raw) + const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data) + return total_count != null ? { definitions, total_count } : { definitions } } /** diff --git a/src/components/content-management/DynamicSchemaSlotField.tsx b/src/components/content-management/DynamicSchemaSlotField.tsx index 61cc808..4acd2db 100644 --- a/src/components/content-management/DynamicSchemaSlotField.tsx +++ b/src/components/content-management/DynamicSchemaSlotField.tsx @@ -17,6 +17,7 @@ import { SpinnerIcon } from "../ui/spinner-icon" import { cn } from "../../lib/utils" import { ResolvedImage } from "../media/ResolvedImage" import { DynamicTableBuilder } from "./DynamicTableBuilder" +import { slotLabel } from "../../lib/schemaSlotLabel" const MAX_IMAGE_BYTES = 10 * 1024 * 1024 const MAX_AUDIO_BYTES = 50 * 1024 * 1024 @@ -30,15 +31,43 @@ export interface DynamicSchemaSlotRow { required?: boolean } -function slotMediaMode(kind: string): "image" | "audio" | "pdf" | "table" | "text" { +function slotMediaMode( + kind: string, +): "image" | "audio" | "pdf" | "table" | "seconds" | "text" { const u = kind.trim().toUpperCase() if (u === "IMAGE") return "image" if (u === "TABLE") return "table" if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf" - if (u.startsWith("AUDIO")) return "audio" + if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds" + if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio" return "text" } +function readSecondsFieldValue(raw: string): string { + const t = raw.trim() + if (!t) return "" + if (/^\d+$/.test(t)) return t + try { + const parsed = JSON.parse(t) as unknown + if (typeof parsed === "number" && Number.isFinite(parsed)) return String(parsed) + if (parsed && typeof parsed === "object" && "seconds" in parsed) { + const seconds = (parsed as { seconds?: unknown }).seconds + if (typeof seconds === "number" && Number.isFinite(seconds)) return String(seconds) + } + } catch { + /* keep raw */ + } + return t +} + +function writeSecondsFieldValue(raw: string): string { + const t = raw.trim() + if (!t) return "" + const n = Number.parseInt(t, 10) + if (!Number.isFinite(n) || n < 0) return "" + return JSON.stringify({ seconds: n }) +} + function isHttpUrl(s: string): boolean { return /^https?:\/\//i.test(s.trim()) } @@ -141,7 +170,9 @@ function DynamicImageSlot({
- {slotMeta} + {slotMeta ? ( + {slotMeta} + ) : null}
- {slotMeta} + {slotMeta ? ( + {slotMeta} + ) : null}
@@ -590,7 +623,9 @@ function DynamicPdfSlot({
- {slotMeta} + {slotMeta ? ( + {slotMeta} + ) : null}
) } + if (mode === "seconds") { + return ( +
+ + onChange(writeSecondsFieldValue(e.target.value))} + placeholder="e.g. 30" + className="h-11 max-w-[200px] rounded-lg border-grayScale-200" + disabled={disabled} + /> +

Stored as seconds (e.g. {`{"seconds": 30}`}).

+
+ ) + } + if (mode === "text") { return (
-
- - {slotMeta} -
+