DocDoeAI / src /lib /api /progress-sync.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
3.62 kB
import { apiGet, apiPost } from "@/lib/api/client";
import type { ActivityEvent, ActivityKind } from "@/lib/student-activity";
type ApiEnvelope<T> = {
success: boolean;
data: T;
meta: {
timestamp: string;
message?: string;
};
};
export type ProgressSyncEvent = {
event_id: string;
event_type: ActivityKind;
title: string;
detail?: string;
subject?: string;
chapter_id?: string;
mission_id?: string;
occurred_at: string;
sample: boolean;
};
export type ProgressSyncResult = {
synced: boolean;
user_id: string;
events_received: number;
events_stored: number;
duplicates: number;
};
export type ProgressSyncSummary = {
user_id: string;
total_sessions: number;
total_events: number;
sample_events: number;
total_duration_seconds: number;
by_type: Partial<Record<ActivityKind, number>>;
last_activity_at: string | null;
recent_events: ActivityEvent[];
};
const activityKinds = new Set<ActivityKind>([
"question_asked",
"lesson_started",
"lesson_completed",
"note_saved",
"quiz_attempted",
"demo_class_completed",
]);
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function toProgressSyncEvent(event: ActivityEvent): ProgressSyncEvent {
return {
event_id: event.id,
event_type: event.kind,
title: event.title,
...(event.detail ? { detail: event.detail } : {}),
...(event.subject ? { subject: event.subject } : {}),
...(event.chapterId ? { chapter_id: event.chapterId } : {}),
...(event.missionId ? { mission_id: event.missionId } : {}),
occurred_at: event.at,
sample: Boolean(event.sample),
};
}
function normalizeRemoteEvent(value: unknown): ActivityEvent | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (
typeof row.id !== "string" ||
typeof row.kind !== "string" ||
!activityKinds.has(row.kind as ActivityKind) ||
typeof row.title !== "string" ||
typeof row.at !== "string" ||
Number.isNaN(Date.parse(row.at))
) {
return null;
}
const kind = row.kind as ActivityKind;
const sample = row.sample === true;
if (kind === "demo_class_completed" && !sample) return null;
return {
id: row.id,
kind,
title: row.title,
at: row.at,
...(optionalString(row.detail) ? { detail: optionalString(row.detail) } : {}),
...(optionalString(row.subject) ? { subject: optionalString(row.subject) } : {}),
...(optionalString(row.chapterId) ? { chapterId: optionalString(row.chapterId) } : {}),
...(optionalString(row.missionId) ? { missionId: optionalString(row.missionId) } : {}),
...(kind === "demo_class_completed"
? { provenance: "demo" as const, sample: true }
: sample
? { sample: true }
: {}),
};
}
export async function syncProgressEvents(
events: ActivityEvent[],
): Promise<ProgressSyncResult> {
const response = await apiPost<
ApiEnvelope<ProgressSyncResult>,
{ events: ProgressSyncEvent[] }
>("/sync/progress", {
events: events.map(toProgressSyncEvent),
});
return response.data;
}
export async function getSyncedProgress(): Promise<ProgressSyncSummary> {
const response =
await apiGet<ApiEnvelope<Omit<ProgressSyncSummary, "recent_events"> & {
recent_events: unknown[];
}>>("/sync/progress");
return {
...response.data,
recent_events: response.data.recent_events
.map(normalizeRemoteEvent)
.filter((event): event is ActivityEvent => event !== null),
};
}