Schema: - 8 neue Profile-Felder fuer DiGA-Demographics (birthYear/gender/maritalStatus/ profession/bundesland/city + 2 consent-stamps demographicsConsentAt/ demographicsWithdrawnAt) - 4 Pro-Trial-Felder (proTrialStartedAt/ExpiresAt/Source/UsedAt) — Free-User bekommen 1 Woche Pro als Reward fuer DiGA-Daten-Pflege (siehe project_demographic_pro_trial_reward.md) - lyra_voice_id (Legend-only Voice-Picker) - diga_banner_dismissed_at (server-side persistence ueber Re-Install) - last_install_at (Streak-Logic survives Re-Install) - Migration 20260507_profile_demographics_and_trial: alle Felder optional, keine Backfill-Logik notwendig Endpoints (alle auth-protected, scope=me): - GET /api/profile/me/sos-insights - GET /api/profile/me/cooldown-history - GET /api/profile/me/approved-domains - POST /api/profile/me/install-event (track app re-installs) - POST /api/profile/me/diga-banner-dismiss - PATCH /api/profile/me/demographics (consent-stamp + re-grant-after-withdrawal in tx) - DELETE /api/profile/me/demographics (DSGVO right-to-be-forgotten) Plugin: - pro-trial-expiry-cron: 6h-Interval, conservative-fallback (revoke nur wenn kein stripeSubId), 60s initial-delay damit Server-boot nicht blockiert wird Tests: - vitest config + erste Test-Files (test-infrastructure setup) Memory: - feedback_demographics_user_initiated.md (Lyra darf NIE extrahieren) - project_demographic_pro_trial_reward.md (Pro-Trial-Reward-Mechanik) - project_profile_page_design.md (UI-Showpiece, eigene/fremde-Ansicht streng getrennt) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
2.8 KiB
TypeScript
101 lines
2.8 KiB
TypeScript
/**
|
|
* GET /api/profile/me/cooldown-history?cursor=<id>&limit=20
|
|
*
|
|
* Cursor-paginated Cooldown-Historie. Frontend nutzt für Profile-Streak-Section
|
|
* (project_profile_page_design.md §3 — Cooldown-Timeline).
|
|
*
|
|
* Status-Berechnung:
|
|
* - resolvedAt set → 'resolved'
|
|
* - cancelledAt set → 'cancelled'
|
|
* - cooldownEndsAt > now → 'active'
|
|
* - cooldownEndsAt <= now & no resolved/cancelled → 'expired' (auto-resolved
|
|
* beim nächsten /cooldown/status-call, hier als 'resolved' angezeigt zur
|
|
* UX-Konsistenz)
|
|
*
|
|
* Response:
|
|
* { items: CooldownEntry[], nextCursor?: string }
|
|
*
|
|
* Pagination via opaque cursor (= last item's id). Frontend persistiert
|
|
* cursor und schickt zurück. Limit 20 (max 50).
|
|
*/
|
|
import { requireUser } from "../../../utils/auth";
|
|
import { usePrisma } from "../../../utils/prisma";
|
|
|
|
const DEFAULT_LIMIT = 20;
|
|
const MAX_LIMIT = 50;
|
|
|
|
type CooldownEntry = {
|
|
id: string;
|
|
startedAt: string;
|
|
cooldownEndsAt: string;
|
|
durationMinutes: number;
|
|
status: "active" | "resolved" | "cancelled";
|
|
resolvedAt: string | null;
|
|
cancelledAt: string | null;
|
|
reason: string | null;
|
|
};
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
const query = getQuery(event);
|
|
|
|
const cursor = typeof query.cursor === "string" ? query.cursor : undefined;
|
|
const limit = Math.min(
|
|
MAX_LIMIT,
|
|
Math.max(1, parseInt(query.limit as string, 10) || DEFAULT_LIMIT),
|
|
);
|
|
|
|
const db = usePrisma();
|
|
const rows = await db.cooldownRequest.findMany({
|
|
where: { userId: user.id },
|
|
orderBy: { cooldownStartedAt: "desc" },
|
|
take: limit + 1, // +1 to know if there's a next page
|
|
...(cursor
|
|
? {
|
|
cursor: { id: cursor },
|
|
skip: 1,
|
|
}
|
|
: {}),
|
|
select: {
|
|
id: true,
|
|
reason: true,
|
|
cooldownStartedAt: true,
|
|
cooldownEndsAt: true,
|
|
resolvedAt: true,
|
|
cancelledAt: true,
|
|
},
|
|
});
|
|
|
|
const hasMore = rows.length > limit;
|
|
const items = (hasMore ? rows.slice(0, limit) : rows).map((r): CooldownEntry => {
|
|
const startedAt = r.cooldownStartedAt;
|
|
const endsAt = r.cooldownEndsAt;
|
|
const durationMinutes = Math.max(
|
|
0,
|
|
Math.round((endsAt.getTime() - startedAt.getTime()) / 60_000),
|
|
);
|
|
let status: CooldownEntry["status"];
|
|
if (r.cancelledAt) status = "cancelled";
|
|
else if (r.resolvedAt || endsAt <= new Date()) status = "resolved";
|
|
else status = "active";
|
|
return {
|
|
id: r.id,
|
|
startedAt: startedAt.toISOString(),
|
|
cooldownEndsAt: endsAt.toISOString(),
|
|
durationMinutes,
|
|
status,
|
|
resolvedAt: r.resolvedAt?.toISOString() ?? null,
|
|
cancelledAt: r.cancelledAt?.toISOString() ?? null,
|
|
reason: r.reason ?? null,
|
|
};
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items,
|
|
nextCursor: hasMore ? items[items.length - 1]?.id : null,
|
|
},
|
|
};
|
|
});
|