chahinebrini 8670b45351 fix(magic): inline mobileconfig template as TS constant
serverAssets approach didn't bundle the template into the Nitro
output (no .output-staging/server/chunks/raw/ dir, no asset-storage
mount in nitro.mjs). Logs confirm: '[Magic] Profile template missing
in serverAssets'.

Drop serverAssets entirely. Inline the template (~2KB) as a TS
constant in backend/server/utils/magic-profile-template.ts. Build-
robust, no FS/storage dependency at runtime. Canonical source of
truth remains ops/mdm/rebreak-mac-dns-filter.mobileconfig — keep in
sync manually until/unless we add a codegen step.
2026-06-03 09:57:27 +02:00

162 lines
4.2 KiB
TypeScript

import { create } from "zustand";
import { apiFetch } from "../lib/api";
export type BlockerStats = {
current: number;
weeklyAdded: number;
monthlyAdded: number;
history: { label: string; count: number }[];
submissions: {
inReview: number;
approved: number;
rejected: number;
};
mySubmissions: {
inReview: number;
approved: number;
rejected: number;
};
avgPerUser: number;
avgApprovalWaitDays: number;
};
type RawStatsResponse = {
current?: unknown;
weeklyAdded?: unknown;
monthlyAdded?: unknown;
history?: Array<{ label?: unknown; count?: unknown }>;
submissions?: {
inVote?: unknown;
inReview?: unknown;
pending?: unknown;
approved?: unknown;
rejected?: unknown;
};
mySubmissions?: {
active?: unknown;
inVote?: unknown;
inReview?: unknown;
pending?: unknown;
approved?: unknown;
rejected?: unknown;
};
avgPerUser?: unknown;
avgApprovalWaitDays?: unknown;
};
type BlockerStatsState = {
stats: BlockerStats | null;
loading: boolean;
error: string | null;
fetchedAt: number | null;
refresh: () => Promise<void>;
refreshIfStale: (maxAgeMs?: number) => Promise<void>;
/** Optimistische lokale Erhöhung von mySubmissions.inReview — damit das Half-Donut
* im ProtectionDetailsSheet sofort die neue Freigabe zeigt, ohne auf den
* 60s-Cache-Refresh zu warten. Der nächste echte refresh() überschreibt den Wert
* ohnehin mit dem Server-State. */
bumpMyInReview: (delta?: number) => void;
};
let inFlight: Promise<void> | null = null;
function asNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function normalizeStats(raw: RawStatsResponse): BlockerStats {
const inReviewGlobal =
asNumber(raw.submissions?.inReview) +
asNumber(raw.submissions?.inVote) +
asNumber(raw.submissions?.pending);
const inReviewMine =
asNumber(raw.mySubmissions?.inReview) +
asNumber(raw.mySubmissions?.inVote) +
asNumber(raw.mySubmissions?.pending);
const approvedMine =
asNumber(raw.mySubmissions?.approved) + asNumber(raw.mySubmissions?.active);
const history = Array.isArray(raw.history)
? raw.history.map((h) => ({
label: typeof h?.label === "string" ? h.label : "",
count: asNumber(h?.count),
}))
: [];
return {
current: asNumber(raw.current),
weeklyAdded: asNumber(raw.weeklyAdded),
monthlyAdded: asNumber(raw.monthlyAdded),
history,
submissions: {
inReview: inReviewGlobal,
approved: asNumber(raw.submissions?.approved),
rejected: asNumber(raw.submissions?.rejected),
},
mySubmissions: {
inReview: inReviewMine,
approved: approvedMine,
rejected: asNumber(raw.mySubmissions?.rejected),
},
avgPerUser: asNumber(raw.avgPerUser),
avgApprovalWaitDays: asNumber(raw.avgApprovalWaitDays),
};
}
export const useBlockerStatsStore = create<BlockerStatsState>((set, get) => ({
stats: null,
loading: false,
error: null,
fetchedAt: null,
refresh: async () => {
if (inFlight) return inFlight;
inFlight = (async () => {
set((s) => ({ ...s, loading: true, error: null }));
try {
const raw = await apiFetch<RawStatsResponse>("/api/blocklist/stats");
const stats = normalizeStats(raw ?? {});
set({ stats, loading: false, error: null, fetchedAt: Date.now() });
} catch (e: any) {
set((s) => ({
...s,
loading: false,
error: e?.message ?? "stats_fetch_failed",
}));
} finally {
inFlight = null;
}
})();
return inFlight;
},
refreshIfStale: async (maxAgeMs = 60_000) => {
const { fetchedAt, stats, refresh } = get();
if (!stats || !fetchedAt || Date.now() - fetchedAt > maxAgeMs) {
await refresh();
}
},
bumpMyInReview: (delta = 1) => {
const { stats } = get();
if (!stats) return;
set({
stats: {
...stats,
mySubmissions: {
...stats.mySubmissions,
inReview: Math.max(0, stats.mySubmissions.inReview + delta),
},
submissions: {
...stats.submissions,
inReview: Math.max(0, stats.submissions.inReview + delta),
},
},
});
},
}));