From efca157969e695ab0c4c9000f50863811c8676f5 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 02:23:27 +0200 Subject: [PATCH] fix(backend): device-mgmt cleanup + stats rejected fallback + realtime refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/rebreak-native/app/(app)/blocker.tsx | 17 ++- apps/rebreak-native/stores/blockerStats.ts | 139 +++++++++++++++++++++ backend/server/api/blocklist/stats.get.ts | 72 ++++++----- backend/server/api/devices/index.get.ts | 7 +- backend/server/db/devices.ts | 45 +++++-- 5 files changed, 241 insertions(+), 39 deletions(-) create mode 100644 apps/rebreak-native/stores/blockerStats.ts diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 627fcaa..4bacd3f 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -20,6 +20,7 @@ import { useBlocklistSync } from '../../hooks/useBlocklistSync'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; import { useColors } from '../../lib/theme'; +import { useBlockerStatsStore } from '../../stores/blockerStats'; export default function BlockerScreen() { const router = useRouter(); @@ -40,6 +41,8 @@ export default function BlockerScreen() { requestDeactivation, cancelDeactivation, } = useProtectionState(); + const refreshBlockerStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale); + const refreshBlockerStats = useBlockerStatsStore((s) => s.refresh); const plan = state?.plan ?? 'free'; const { @@ -57,14 +60,20 @@ export default function BlockerScreen() { // Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen. const onDomainChange = useCallback(async () => { await refreshDomains(); + await refreshBlockerStats().catch(() => {}); if (urlFilterActiveRef.current) { const sync = await syncBlocklist(); console.log('[blocker] resync after domain change:', sync); await refresh(); } - }, [refreshDomains, syncBlocklist, refresh]); + }, [refreshDomains, refreshBlockerStats, syncBlocklist, refresh]); 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 [addSheetOpen, setAddSheetOpen] = useState(false); const [vipSwapOpen, setVipSwapOpen] = useState(false); @@ -80,6 +89,7 @@ export default function BlockerScreen() { const familyControlsActive = state?.layers.familyControls === true; const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true; const nefilterActive = state?.layers.nefilterActive === true; + const accessibilityActive = state?.layers.accessibility === true; // "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock // (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval — // 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 // Profile + non-removable App enforced, FC-Toggle ist irrelevant. // nefilterActive → Schutz via System-Profil, kein VPN-Toggle nötig → locked-in - const lockedIn = - (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); + const lockedIn = Platform.OS === 'android' + ? (urlFilterActive && accessibilityActive) + : (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); const urlFilterActiveRef = useRef(urlFilterActive); useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); diff --git a/apps/rebreak-native/stores/blockerStats.ts b/apps/rebreak-native/stores/blockerStats.ts new file mode 100644 index 0000000..fe8ebbb --- /dev/null +++ b/apps/rebreak-native/stores/blockerStats.ts @@ -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; + refreshIfStale: (maxAgeMs?: number) => Promise; +}; + +let inFlight: Promise | 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((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('/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(); + } + }, +})); diff --git a/backend/server/api/blocklist/stats.get.ts b/backend/server/api/blocklist/stats.get.ts index a1f43f1..f6c633f 100644 --- a/backend/server/api/blocklist/stats.get.ts +++ b/backend/server/api/blocklist/stats.get.ts @@ -2,7 +2,6 @@ import { getActiveBlocklistCount } from "../../db/domains"; import { usePrisma } from "../../utils/prisma"; const ADGUARD_KNOWN_COUNT = 208704; -const VOTE_PHASE_DAYS = 7; /** GET /api/blocklist/stats */ 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 monthAgo = new Date(now.getTime() - 30 * 86_400_000); const [ - inVote, - inReview, + pendingCount, + inReviewCount, + approvedCount, + rejectedCount, + rejectedNotificationCount, approvedAgg, totalSubmittersGroup, weeklyAdded, monthlyAdded, - mineActive, - mineInVote, + mineApproved, + minePending, mineInReview, + mineRejected, + mineRejectedNotifications, ] = await Promise.all([ - db.domainSubmission.count({ - where: { status: "pending" }, - }), - db.domainSubmission.count({ - where: { status: "in_review" }, - }), + db.domainSubmission.count({ where: { status: "pending" } }), + db.domainSubmission.count({ where: { status: "in_review" } }), + db.domainSubmission.count({ where: { status: "approved" } }), + db.domainSubmission.count({ where: { status: "rejected" } }), + db.notification.count({ where: { type: "domain_rejected" } }), db.domainSubmission.findMany({ where: { status: "approved", reviewedAt: { not: null } }, select: { createdAt: true, reviewedAt: true }, @@ -78,23 +79,29 @@ export default defineEventHandler(async (event) => { ? db.userCustomDomain.count({ where: { userId, status: "approved" } }) : Promise.resolve(0), userId - ? db.domainSubmission.count({ - where: { - userId, - status: "pending", - }, - }) + ? db.domainSubmission.count({ where: { userId, status: "pending" } }) : Promise.resolve(0), userId - ? db.domainSubmission.count({ - where: { - userId, - status: "in_review", - }, + ? db.domainSubmission.count({ where: { userId, status: "in_review" } }) + : Promise.resolve(0), + userId + ? db.domainSubmission.count({ where: { userId, status: "rejected" } }) + : Promise.resolve(0), + userId + ? db.notification.count({ + where: { recipientId: userId, type: "domain_rejected" }, }) : 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; if (approvedAgg.length > 0) { const totalMs = approvedAgg.reduce((sum, s) => { @@ -120,11 +127,20 @@ export default defineEventHandler(async (event) => { weeklyAdded, monthlyAdded, 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: { - active: mineActive, - inVote: mineInVote, - inReview: mineInReview, + inReview: mineCombinedInReview, + approved: mineApproved, + rejected: mineCombinedRejected, + // Legacy fields for older clients. + active: mineApproved, + inVote: 0, }, avgPerUser, avgApprovalWaitDays, diff --git a/backend/server/api/devices/index.get.ts b/backend/server/api/devices/index.get.ts index 23bbe67..ff55448 100644 --- a/backend/server/api/devices/index.get.ts +++ b/backend/server/api/devices/index.get.ts @@ -1,4 +1,4 @@ -import { listUserDevices } from "../../db/devices"; +import { listUserDevices, cleanupStaleDevices } from "../../db/devices"; import { getProfile } from "../../db/profile"; 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 * 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) => { // 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 limits = getPlanLimits(profile?.plan ?? "free"); + await cleanupStaleDevices(user.id); + const currentDeviceId = getHeader(event, "x-device-id") ?? null; const devices = await listUserDevices(user.id); diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 3bdbc66..d4c2b03 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -197,16 +197,43 @@ const DEVICE_SELECT = { // Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries 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 { const db = usePrisma(); return db.userDevice.findMany({ where: { userId }, - orderBy: { lastSeenAt: "desc" }, + orderBy: [ + { lastSeenAt: "desc" }, + { createdAt: "desc" }, + { id: "asc" }, + ], 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 { + 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. */ export async function findUserDevice( userId: string, @@ -221,13 +248,12 @@ export async function findUserDevice( /** * 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 * deviceId, aber gleicher name ("iPhone von Chahine") + gleiches model * ("iPhone18,4"). * - * Bekannter Trade-off: zwei physisch identische iPhones mit gleichem Namen - * würden gemerged. Edge-case, bewusst akzeptiert. + * 2026-06-01: Cutoff 30d → 7d (verhindert False-Merges nach längerer Inaktivität). */ async function findMergeCandidate( userId: string, @@ -237,7 +263,7 @@ async function findMergeCandidate( if (!name || !model) return null; 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({ where: { @@ -307,7 +333,12 @@ export async function registerDevice(opts: { 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 } }); if (count >= opts.maxDevices) { throw Object.assign(new Error("device_limit_reached"), {