fix(backend): device-mgmt cleanup + stats rejected fallback + realtime refresh

- devices: cleanupStaleDevices() purges phantoms >14d not bound; called from
  GET /api/devices + register limit-check. Deterministic sort
  (lastSeenAt, createdAt, id) so iPad+iPhone see identical order.
  Merge-cutoff tightened 30d -> 7d.
- stats: rejected aggregates from notifications(type='domain_rejected')
  via Math.max — admin reject cascade-deletes submission row.
- blocker.tsx: useDomainSubmissionRealtime triggers blockerStats.refresh()
  directly (not stale-check only) so info-sheet shows fresh rejected count.
This commit is contained in:
chahinebrini 2026-06-01 02:23:27 +02:00
parent 578abfe3bb
commit efca157969
5 changed files with 241 additions and 39 deletions

View File

@ -20,6 +20,7 @@ import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { useBlockerStatsStore } from '../../stores/blockerStats';
export default function BlockerScreen() { export default function BlockerScreen() {
const router = useRouter(); const router = useRouter();
@ -40,6 +41,8 @@ export default function BlockerScreen() {
requestDeactivation, requestDeactivation,
cancelDeactivation, cancelDeactivation,
} = useProtectionState(); } = useProtectionState();
const refreshBlockerStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale);
const refreshBlockerStats = useBlockerStatsStore((s) => s.refresh);
const plan = state?.plan ?? 'free'; const plan = state?.plan ?? 'free';
const { const {
@ -57,14 +60,20 @@ export default function BlockerScreen() {
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen. // Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
const onDomainChange = useCallback(async () => { const onDomainChange = useCallback(async () => {
await refreshDomains(); await refreshDomains();
await refreshBlockerStats().catch(() => {});
if (urlFilterActiveRef.current) { if (urlFilterActiveRef.current) {
const sync = await syncBlocklist(); const sync = await syncBlocklist();
console.log('[blocker] resync after domain change:', sync); console.log('[blocker] resync after domain change:', sync);
await refresh(); await refresh();
} }
}, [refreshDomains, syncBlocklist, refresh]); }, [refreshDomains, refreshBlockerStats, syncBlocklist, refresh]);
useDomainSubmissionRealtime(onDomainChange, true); useDomainSubmissionRealtime(onDomainChange, true);
// Stats fürs Info-Sheet früh laden, damit beim Öffnen kein Loader-Flicker entsteht.
useEffect(() => {
refreshBlockerStatsIfStale(120_000).catch(() => {});
}, [refreshBlockerStatsIfStale]);
const [vipOpen, setVipOpen] = useState(false); const [vipOpen, setVipOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false); const [addSheetOpen, setAddSheetOpen] = useState(false);
const [vipSwapOpen, setVipSwapOpen] = useState(false); const [vipSwapOpen, setVipSwapOpen] = useState(false);
@ -80,6 +89,7 @@ export default function BlockerScreen() {
const familyControlsActive = state?.layers.familyControls === true; const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true; const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
const nefilterActive = state?.layers.nefilterActive === true; const nefilterActive = state?.layers.nefilterActive === true;
const accessibilityActive = state?.layers.accessibility === true;
// "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock // "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval — // (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE // ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
@ -90,8 +100,9 @@ export default function BlockerScreen() {
// - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares // - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares
// Profile + non-removable App enforced, FC-Toggle ist irrelevant. // Profile + non-removable App enforced, FC-Toggle ist irrelevant.
// nefilterActive → Schutz via System-Profil, kein VPN-Toggle nötig → locked-in // nefilterActive → Schutz via System-Profil, kein VPN-Toggle nötig → locked-in
const lockedIn = const lockedIn = Platform.OS === 'android'
(nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); ? (urlFilterActive && accessibilityActive)
: (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
const urlFilterActiveRef = useRef(urlFilterActive); const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);

View File

@ -0,0 +1,139 @@
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>;
};
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();
}
},
}));

View File

@ -2,7 +2,6 @@ import { getActiveBlocklistCount } from "../../db/domains";
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
const ADGUARD_KNOWN_COUNT = 208704; const ADGUARD_KNOWN_COUNT = 208704;
const VOTE_PHASE_DAYS = 7;
/** GET /api/blocklist/stats */ /** GET /api/blocklist/stats */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -37,27 +36,29 @@ export default defineEventHandler(async (event) => {
); );
} }
// Submissions split: vote phase (created < 7d) vs review (older, awaiting admin)
const voteCutoff = new Date(now.getTime() - VOTE_PHASE_DAYS * 86_400_000);
const weekAgo = new Date(now.getTime() - 7 * 86_400_000); const weekAgo = new Date(now.getTime() - 7 * 86_400_000);
const monthAgo = new Date(now.getTime() - 30 * 86_400_000); const monthAgo = new Date(now.getTime() - 30 * 86_400_000);
const [ const [
inVote, pendingCount,
inReview, inReviewCount,
approvedCount,
rejectedCount,
rejectedNotificationCount,
approvedAgg, approvedAgg,
totalSubmittersGroup, totalSubmittersGroup,
weeklyAdded, weeklyAdded,
monthlyAdded, monthlyAdded,
mineActive, mineApproved,
mineInVote, minePending,
mineInReview, mineInReview,
mineRejected,
mineRejectedNotifications,
] = await Promise.all([ ] = await Promise.all([
db.domainSubmission.count({ db.domainSubmission.count({ where: { status: "pending" } }),
where: { status: "pending" }, db.domainSubmission.count({ where: { status: "in_review" } }),
}), db.domainSubmission.count({ where: { status: "approved" } }),
db.domainSubmission.count({ db.domainSubmission.count({ where: { status: "rejected" } }),
where: { status: "in_review" }, db.notification.count({ where: { type: "domain_rejected" } }),
}),
db.domainSubmission.findMany({ db.domainSubmission.findMany({
where: { status: "approved", reviewedAt: { not: null } }, where: { status: "approved", reviewedAt: { not: null } },
select: { createdAt: true, reviewedAt: true }, select: { createdAt: true, reviewedAt: true },
@ -78,23 +79,29 @@ export default defineEventHandler(async (event) => {
? db.userCustomDomain.count({ where: { userId, status: "approved" } }) ? db.userCustomDomain.count({ where: { userId, status: "approved" } })
: Promise.resolve(0), : Promise.resolve(0),
userId userId
? db.domainSubmission.count({ ? db.domainSubmission.count({ where: { userId, status: "pending" } })
where: {
userId,
status: "pending",
},
})
: Promise.resolve(0), : Promise.resolve(0),
userId userId
? db.domainSubmission.count({ ? db.domainSubmission.count({ where: { userId, status: "in_review" } })
where: { : Promise.resolve(0),
userId, userId
status: "in_review", ? db.domainSubmission.count({ where: { userId, status: "rejected" } })
}, : Promise.resolve(0),
userId
? db.notification.count({
where: { recipientId: userId, type: "domain_rejected" },
}) })
: Promise.resolve(0), : Promise.resolve(0),
]); ]);
const combinedInReview = pendingCount + inReviewCount;
const mineCombinedInReview = minePending + mineInReview;
// Admin-Reject-Flow kann Submissions historisch löschen (Cascade),
// Notifications bleiben jedoch bestehen. Deshalb nehmen wir das Maximum,
// damit Rejections in den KPIs nicht auf 0 zurückfallen.
const combinedRejected = Math.max(rejectedCount, rejectedNotificationCount);
const mineCombinedRejected = Math.max(mineRejected, mineRejectedNotifications);
let avgApprovalWaitDays = 0; let avgApprovalWaitDays = 0;
if (approvedAgg.length > 0) { if (approvedAgg.length > 0) {
const totalMs = approvedAgg.reduce((sum, s) => { const totalMs = approvedAgg.reduce((sum, s) => {
@ -120,11 +127,20 @@ export default defineEventHandler(async (event) => {
weeklyAdded, weeklyAdded,
monthlyAdded, monthlyAdded,
history: labels.map((label, i) => ({ label, count: values[i] })), history: labels.map((label, i) => ({ label, count: values[i] })),
submissions: { inVote, inReview }, submissions: {
inReview: combinedInReview,
approved: approvedCount,
rejected: combinedRejected,
// Legacy fields for older clients.
inVote: 0,
},
mySubmissions: { mySubmissions: {
active: mineActive, inReview: mineCombinedInReview,
inVote: mineInVote, approved: mineApproved,
inReview: mineInReview, rejected: mineCombinedRejected,
// Legacy fields for older clients.
active: mineApproved,
inVote: 0,
}, },
avgPerUser, avgPerUser,
avgApprovalWaitDays, avgApprovalWaitDays,

View File

@ -1,4 +1,4 @@
import { listUserDevices } from "../../db/devices"; import { listUserDevices, cleanupStaleDevices } from "../../db/devices";
import { getProfile } from "../../db/profile"; import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features"; import { getPlanLimits } from "../../utils/plan-features";
@ -7,6 +7,9 @@ import { getPlanLimits } from "../../utils/plan-features";
* *
* Liste aller registrierten Devices des Users + plan-limit + welches Device der * Liste aller registrierten Devices des Users + plan-limit + welches Device der
* aktuelle Caller ist (matched via x-device-id header). * aktuelle Caller ist (matched via x-device-id header).
*
* Ruft cleanupStaleDevices() VOR dem Listing damit Phantom-Devices (>30d inaktiv,
* nicht bound) nicht in der UI auftauchen.
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// skipDeviceCheck: User der gerade vom Geräte-Limit blockt wird, soll trotzdem // skipDeviceCheck: User der gerade vom Geräte-Limit blockt wird, soll trotzdem
@ -15,6 +18,8 @@ export default defineEventHandler(async (event) => {
const profile = await getProfile(user.id); const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free"); const limits = getPlanLimits(profile?.plan ?? "free");
await cleanupStaleDevices(user.id);
const currentDeviceId = getHeader(event, "x-device-id") ?? null; const currentDeviceId = getHeader(event, "x-device-id") ?? null;
const devices = await listUserDevices(user.id); const devices = await listUserDevices(user.id);

View File

@ -197,16 +197,43 @@ const DEVICE_SELECT = {
// Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries // Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries
const DEVICE_SELECT_WITH_LOCK = DEVICE_SELECT; const DEVICE_SELECT_WITH_LOCK = DEVICE_SELECT;
/** Liste aller Devices eines Users, aktuellstes zuerst. */ /** Liste aller Devices eines Users, aktuellstes zuerst.
* Deterministic sort: lastSeenAt DESC, createdAt DESC, id ASC stellt sicher
* dass iPad + iPhone die GLEICHE Reihenfolge sehen wenn lastSeenAt identisch ist. */
export async function listUserDevices(userId: string): Promise<DeviceRecord[]> { export async function listUserDevices(userId: string): Promise<DeviceRecord[]> {
const db = usePrisma(); const db = usePrisma();
return db.userDevice.findMany({ return db.userDevice.findMany({
where: { userId }, where: { userId },
orderBy: { lastSeenAt: "desc" }, orderBy: [
{ lastSeenAt: "desc" },
{ createdAt: "desc" },
{ id: "asc" },
],
select: DEVICE_SELECT, select: DEVICE_SELECT,
}); });
} }
/**
* Löscht Phantom-Devices: >14 Tage nicht gesehen + nicht an Plan gebunden.
* Wird von GET /api/devices + register-Limit-Check aufgerufen damit alte
* IDFV-Reset-Artifacts nicht das Device-Limit blockieren oder in der UI
* herumgeistern.
*
* Returnt Anzahl gelöschter Rows (für Logging).
*/
export async function cleanupStaleDevices(userId: string): Promise<number> {
const db = usePrisma();
const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
const res = await db.userDevice.deleteMany({
where: {
userId,
lastSeenAt: { lt: cutoff },
boundToPlan: null,
},
});
return res.count;
}
/** Gibt das Device zurück wenn registriert; sonst null. */ /** Gibt das Device zurück wenn registriert; sonst null. */
export async function findUserDevice( export async function findUserDevice(
userId: string, userId: string,
@ -221,13 +248,12 @@ export async function findUserDevice(
/** /**
* Sucht nach einem "Merge-Kandidaten": existierendes Device des Users mit * Sucht nach einem "Merge-Kandidaten": existierendes Device des Users mit
* identischem name + model das zuletzt innerhalb der letzten 30 Tage gesehen * identischem name + model das zuletzt innerhalb der letzten 7 Tage gesehen
* wurde. Tritt auf wenn iOS IDFV sich nach Recovery-Restore ändert neuer * wurde. Tritt auf wenn iOS IDFV sich nach Recovery-Restore ändert neuer
* deviceId, aber gleicher name ("iPhone von Chahine") + gleiches model * deviceId, aber gleicher name ("iPhone von Chahine") + gleiches model
* ("iPhone18,4"). * ("iPhone18,4").
* *
* Bekannter Trade-off: zwei physisch identische iPhones mit gleichem Namen * 2026-06-01: Cutoff 30d 7d (verhindert False-Merges nach längerer Inaktivität).
* würden gemerged. Edge-case, bewusst akzeptiert.
*/ */
async function findMergeCandidate( async function findMergeCandidate(
userId: string, userId: string,
@ -237,7 +263,7 @@ async function findMergeCandidate(
if (!name || !model) return null; if (!name || !model) return null;
const db = usePrisma(); const db = usePrisma();
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const candidate = await db.userDevice.findFirst({ const candidate = await db.userDevice.findFirst({
where: { where: {
@ -307,7 +333,12 @@ export async function registerDevice(opts: {
return { device: merged, created: false, merged: true }; return { device: merged, created: false, merged: true };
} }
// Neues Device — Limit prüfen // Neues Device — Limit prüfen.
// 2026-06-01 Auto-Cleanup: alte Phantom-Devices (z.B. nach iOS-Reset mit
// geändertem Namen, die durch Merge-Heuristik durchgerutscht sind) blockieren
// sonst das Limit.
await cleanupStaleDevices(opts.userId);
const count = await db.userDevice.count({ where: { userId: opts.userId } }); const count = await db.userDevice.count({ where: { userId: opts.userId } });
if (count >= opts.maxDevices) { if (count >= opts.maxDevices) {
throw Object.assign(new Error("device_limit_reached"), { throw Object.assign(new Error("device_limit_reached"), {