- 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>
87 lines
2.6 KiB
TypeScript
87 lines
2.6 KiB
TypeScript
import { usePrisma } from "../../utils/prisma";
|
|
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
|
import type { Plan } from "../../utils/plan-features";
|
|
|
|
const VALID_PLANS = ["free", "pro", "legend"] as const;
|
|
type AppPlan = (typeof VALID_PLANS)[number];
|
|
|
|
/**
|
|
* POST /api/dev/set-plan
|
|
*
|
|
* DEV/STAGING-ONLY: Setzt den eigenen Plan ohne Admin-Rechte.
|
|
* Blocked in Production (appUrl enthält "rebreak.org" aber NICHT "staging").
|
|
*
|
|
* Body: { plan: "free" | "pro" | "legend", foundingMember?: boolean }
|
|
* Response: { success: true, plan: AppPlan, foundingMember: boolean, reconciled: boolean }
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
|
|
// Prod-Guard: analog cooldown/request.post.ts
|
|
const config = useRuntimeConfig(event);
|
|
const appUrl = (config.public?.appUrl as string) ?? "";
|
|
const isProductionUrl =
|
|
appUrl.includes("rebreak.org") && !appUrl.includes("staging");
|
|
if (isProductionUrl) {
|
|
throw createError({ statusCode: 403, message: "dev-only" });
|
|
}
|
|
|
|
const body = await readBody(event).catch(() => ({}));
|
|
const plan = body?.plan as string | undefined;
|
|
const setFoundingMember = body?.foundingMember as boolean | undefined;
|
|
|
|
if (!plan || !(VALID_PLANS as readonly string[]).includes(plan)) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
data: {
|
|
error: "INVALID_PLAN",
|
|
message: `plan must be one of: ${VALID_PLANS.join(", ")}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
const db = usePrisma();
|
|
|
|
// Aktuellen Plan lesen für Reconciliation
|
|
const current = await db.profile.findUnique({
|
|
where: { id: user.id },
|
|
select: { plan: true, foundingMember: true },
|
|
});
|
|
|
|
const fromPlan = (current?.plan ?? "free") as Plan;
|
|
const toPlan = plan as AppPlan;
|
|
|
|
// Plan + optional foundingMember setzen
|
|
const updateData: Record<string, unknown> = { plan: toPlan };
|
|
if (typeof setFoundingMember === "boolean") {
|
|
updateData.foundingMember = setFoundingMember;
|
|
}
|
|
|
|
await db.profile.update({
|
|
where: { id: user.id },
|
|
data: updateData,
|
|
});
|
|
|
|
// Downgrade-Reconciliation (überspringt automatisch wenn foundingMember=true)
|
|
let reconciled = false;
|
|
try {
|
|
await runDowngradeReconciliation(user.id, fromPlan, toPlan);
|
|
reconciled = true;
|
|
} catch (err) {
|
|
// Reconciliation-Fehler darf Plan-Wechsel nicht blockieren
|
|
console.error("[set-plan] reconciliation error:", err);
|
|
}
|
|
|
|
const updated = await db.profile.findUnique({
|
|
where: { id: user.id },
|
|
select: { foundingMember: true },
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
plan: toPlan,
|
|
foundingMember: updated?.foundingMember ?? false,
|
|
reconciled,
|
|
};
|
|
});
|