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} -
+