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 { 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]);

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";
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,

View File

@ -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);

View File

@ -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<DeviceRecord[]> {
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<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. */
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"), {