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.
162 lines
4.2 KiB
TypeScript
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),
|
|
},
|
|
},
|
|
});
|
|
},
|
|
}));
|