chahinebrini 335945fe2c feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub,
  TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split
  (legend maxProtectedDevices: 2); mail 1/3/Infinity
- limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll
  ({ error:'plan_limit', resource, current, limit }); approved-own-submissions already
  excluded from custom-domain count (slot frees on approval)
- server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade
  reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts
  (isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final
  sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles
  (status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d;
  custom domains grandfathered
- set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change;
  set-plan accepts { foundingMember } for testing
- GET /api/plan/change-preview?to=<plan>: gains/keeps/changes per resource (8 axes),
  founding-member → direction 'same'
- me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block
- blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full')
- db: countMailConnections/getMailConnections exclude paused; getAllMailConnections;
  getDeviceBlocklistMode (active|grace|passthrough|revoked)
- migration 20260511_tier_system_phase2 (profiles.founding_member +
  global_blocklist_grace_until; mail_connections.paused_at/paused_reason;
  protected_devices.degraded_at). prisma generate + build:backend clean.

TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP
final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the
stub into the blocklist response for free users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:23:02 +02:00

232 lines
6.2 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
export async function getMailConnections(userId: string) {
const db = usePrisma();
// isActive=true UND nicht pausiert (pausedAt=null) — pausierte werden vom Cron ausgelassen
return db.mailConnection.findMany({
where: { userId, isActive: true, pausedAt: null },
orderBy: { createdAt: "asc" },
});
}
/** Alle Verbindungen eines Users inkl. pausierten — für Status-Anzeige im Frontend. */
export async function getAllMailConnections(userId: string) {
const db = usePrisma();
return db.mailConnection.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
provider: true,
providerName: true,
isActive: true,
pausedAt: true,
pausedReason: true,
scanInterval: true,
lastScannedAt: true,
nextScanAt: true,
emailsBlocked: true,
emailsScanned: true,
lastConnectError: true,
createdAt: true,
},
});
}
export async function getAllActiveMailUserIds() {
const db = usePrisma();
const rows = await db.mailConnection.findMany({
where: { isActive: true, nextScanAt: { lte: new Date() } },
select: { userId: true },
distinct: ["userId"],
});
return rows.map((r) => r.userId);
}
export async function countMailConnections(userId: string) {
const db = usePrisma();
// Nur aktive + nicht-pausierte Verbindungen zählen gegen das Limit
return db.mailConnection.count({ where: { userId, isActive: true, pausedAt: null } });
}
export async function upsertMailConnection(data: {
userId: string;
email: string;
provider: string;
providerName: string;
imapHost: string;
imapPort: number;
passwordEncrypted: string;
rejectUnauthorized?: boolean;
useStarttls?: boolean;
}) {
const db = usePrisma();
return db.mailConnection.upsert({
where: { userId_email: { userId: data.userId, email: data.email } },
create: {
...data,
isActive: true,
rejectUnauthorized: data.rejectUnauthorized ?? true,
useStarttls: data.useStarttls ?? false,
},
update: {
providerName: data.providerName,
imapHost: data.imapHost,
imapPort: data.imapPort,
passwordEncrypted: data.passwordEncrypted,
rejectUnauthorized: data.rejectUnauthorized ?? true,
useStarttls: data.useStarttls ?? false,
isActive: true,
// Bei Re-Connect (z.B. neues App-Passwort): alte Error-Spuren clearen,
// damit UI sofort wieder "Live" zeigt — IDLE-daemon übernimmt.
lastConnectError: null,
lastConnectErrorAt: null,
},
});
}
export async function deleteMailConnection(
userId: string,
connectionId: string,
) {
const db = usePrisma();
return db.mailConnection.deleteMany({
where: { id: connectionId, userId },
});
}
export async function deleteAllMailConnections(userId: string) {
const db = usePrisma();
return db.mailConnection.deleteMany({ where: { userId } });
}
export async function updateMailConnectionInterval(
userId: string,
connectionId: string,
interval: number,
) {
const db = usePrisma();
return db.mailConnection.updateMany({
where: { id: connectionId, userId },
data: { scanInterval: interval },
});
}
export async function updateMailConnectionScanStats(
connectionId: string,
scanned: number,
blocked: number,
currentBlocked: number,
currentScanned: number,
scanIntervalHours: number,
) {
const db = usePrisma();
return db.mailConnection.update({
where: { id: connectionId },
data: {
lastScannedAt: new Date(),
emailsBlocked: currentBlocked + blocked,
emailsScanned: currentScanned + scanned,
nextScanAt: new Date(Date.now() + scanIntervalHours * 3_600_000),
},
});
}
export async function getMailBlockedStats(userId: string) {
const db = usePrisma();
const since7d = new Date(Date.now() - 7 * 86_400_000);
return db.mailBlocked.findMany({
where: { userId, createdAt: { gte: since7d } },
select: { createdAt: true },
});
}
export async function isMailAlreadyBlocked(
gmailMessageId: string,
userId: string,
) {
const db = usePrisma();
const existing = await db.mailBlocked.findFirst({
where: { gmailMessageId, userId },
select: { id: true },
});
return !!existing;
}
export async function getAlreadyBlockedUidSet(
uids: string[],
userId: string,
): Promise<Set<string>> {
if (uids.length === 0) return new Set();
const db = usePrisma();
const existing = await db.mailBlocked.findMany({
where: { gmailMessageId: { in: uids }, userId },
select: { gmailMessageId: true },
});
return new Set(existing.map((e) => e.gmailMessageId));
}
export async function insertMailBlocked(
entries: {
userId: string;
connectionId: string;
gmailMessageId: string;
senderEmail: string;
senderName: string | null;
subject: string;
receivedAt: Date;
action: string;
}[],
) {
if (entries.length === 0) return;
const db = usePrisma();
await db.mailBlocked.createMany({ data: entries, skipDuplicates: true });
}
export async function getImapProxyAccounts(userId: string) {
const db = usePrisma();
return db.imapProxyAccount.findMany({ where: { userId } });
}
export async function upsertImapProxyAccount(data: {
userId: string;
proxyUsername: string;
proxyPassword: string;
connectionId: string;
}) {
const db = usePrisma();
return db.imapProxyAccount.upsert({
where: { connectionId: data.connectionId },
create: data,
update: { proxyPassword: data.proxyPassword },
});
}
export async function deleteOldMailBlocked(userId: string) {
const db = usePrisma();
const cutoff = new Date(Date.now() - 24 * 3_600_000);
return db.mailBlocked.deleteMany({
where: { userId, createdAt: { lt: cutoff } },
});
}
export async function getMailBlockedPaginated(
userId: string,
page: number,
limit = 20,
) {
const db = usePrisma();
const offset = (page - 1) * limit;
const [results, total] = await Promise.all([
db.mailBlocked.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
}),
db.mailBlocked.count({ where: { userId } }),
]);
return { results, total, page, pages: Math.ceil(total / limit) };
}