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:
parent
578abfe3bb
commit
efca157969
@ -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]);
|
||||||
|
|||||||
139
apps/rebreak-native/stores/blockerStats.ts
Normal file
139
apps/rebreak-native/stores/blockerStats.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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"), {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user