Merge managed record settings

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 04:05:16 +02:00
commit 201a24e404
36 files changed
+3712 -14

No files matched your search

@@ -0,0 +1,396 @@
import type { NextRequest } from "next/server";
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
invalidateUserHealthScore,
invalidateUserInsightsLayout,
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { prisma, toJson } from "@/lib/db";
import {
getAllEffectiveRanges,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import {
isManagedRecordSettingsFamily,
managedModulePreferencesFrom,
parseManagedRecordSettingsPatch,
resolveGuardianRecordSettingsAccess,
} from "@/lib/record-settings";
import {
resolveInsightsLayout,
serializeInsightsLayout,
} from "@/lib/insights-layout";
import { annotate } from "@/lib/logging/context";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import {
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ family: string }> };
async function resolveManagedRecordSettings(params: RouteParams["params"]) {
const { family: rawFamily } = await params;
if (!isManagedRecordSettingsFamily(rawFamily)) return null;
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
return { access, family: rawFamily };
}
/** Each response names the target record and only one field-specific DTO. */
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const resolved = await resolveManagedRecordSettings(params);
if (!resolved) return apiError("Settings family not found", 404);
const { access, family } = resolved;
const record = await prisma.user.findUnique({
where: { id: access.recordId },
select: {
displayName: true,
heightCm: true,
dateOfBirth: true,
gender: true,
locale: true,
timezone: true,
unitPreference: true,
timeFormat: true,
dateFormat: true,
modulePreferencesJson: true,
moodReminderEnabled: true,
notificationPrefs: true,
thresholdsJson: true,
disableCoach: true,
coachPrefsJson: true,
insightsLayoutJson: true,
},
});
if (!record) return apiError("Record not found", 404);
let settings: unknown;
switch (family) {
case "profile":
settings = {
displayName: record.displayName,
heightCm: record.heightCm,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
gender: record.gender,
locale: record.locale,
timezone: record.timezone,
unitPreference: record.unitPreference,
timeFormat: record.timeFormat,
dateFormat: record.dateFormat,
};
break;
case "modules":
settings = {
modulePreferences: managedModulePreferencesFrom(
record.modulePreferencesJson,
),
};
break;
case "notifications": {
const preferences = parseNotificationPrefs(record.notificationPrefs);
settings = {
moodReminderEnabled: record.moodReminderEnabled,
notificationPreferences: {
medication: {
lowStockRunwayDays: preferences.medication.lowStockRunwayDays,
reorderLeadDays: preferences.medication.reorderLeadDays,
},
mood: { reminderHour: preferences.mood.reminderHour },
},
};
break;
}
case "thresholds": {
const overrides = (record.thresholdsJson ??
{}) as ThresholdOverridesJson;
settings = {
overrides,
effective: getAllEffectiveRanges(record, overrides),
};
break;
}
case "coach": {
const preferences = parseCoachPrefs(record.coachPrefsJson);
settings = {
disableCoach: record.disableCoach,
preferences: {
tone: preferences.tone,
verbosity: preferences.verbosity,
excludeMetrics: preferences.excludeMetrics,
showEvidenceByDefault: preferences.showEvidenceByDefault,
defaultWindow: preferences.defaultWindow,
...(preferences.dataClusters !== undefined
? { dataClusters: preferences.dataClusters }
: {}),
},
};
break;
}
case "insights":
settings = { layout: resolveInsightsLayout(record.insightsLayoutJson) };
break;
}
annotate({ action: { name: `record-settings.${family}.get` } });
return apiSuccess({ recordId: access.recordId, family, settings });
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const resolved = await resolveManagedRecordSettings(params);
if (!resolved) return apiError("Settings family not found", 404);
const { access, family } = resolved;
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
try {
let settings: unknown;
let changed: readonly string[];
switch (family) {
case "profile": {
const patch = parseManagedRecordSettingsPatch("profile", body);
const data = {
...(patch.displayName !== undefined
? { displayName: patch.displayName }
: {}),
...(patch.heightCm !== undefined
? { heightCm: patch.heightCm }
: {}),
...(patch.dateOfBirth !== undefined
? {
dateOfBirth: patch.dateOfBirth
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
: null,
}
: {}),
...(patch.gender !== undefined ? { gender: patch.gender } : {}),
...(patch.locale !== undefined ? { locale: patch.locale } : {}),
...(patch.timezone !== undefined
? { timezone: patch.timezone }
: {}),
...(patch.unitPreference !== undefined
? { unitPreference: patch.unitPreference }
: {}),
...(patch.timeFormat !== undefined
? { timeFormat: patch.timeFormat }
: {}),
...(patch.dateFormat !== undefined
? { dateFormat: patch.dateFormat }
: {}),
};
const record = await prisma.user.update({
where: { id: access.recordId },
data,
select: {
displayName: true,
heightCm: true,
dateOfBirth: true,
gender: true,
locale: true,
timezone: true,
unitPreference: true,
timeFormat: true,
dateFormat: true,
},
});
invalidateUserProfile(access.recordId);
if (patch.timezone !== undefined) {
invalidateUserMedications(access.recordId, { evict: true });
}
settings = {
...record,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
};
changed = Object.keys(patch);
break;
}
case "modules": {
const patch = parseManagedRecordSettingsPatch("modules", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
select: { modulePreferencesJson: true },
});
const modulePreferences = {
...managedModulePreferencesFrom(current?.modulePreferencesJson),
...patch.modulePreferences,
};
await prisma.user.update({
where: { id: access.recordId },
data: { modulePreferencesJson: toJson(modulePreferences) },
});
invalidateUserHealthScore(access.recordId);
settings = { modulePreferences };
changed = Object.keys(patch.modulePreferences);
break;
}
case "notifications": {
const patch = parseManagedRecordSettingsPatch("notifications", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
select: { notificationPrefs: true },
});
const notificationPreferences = patch.notificationPreferences
? resolveNotificationPrefs(
current?.notificationPrefs,
patch.notificationPreferences,
)
: undefined;
const record = await prisma.user.update({
where: { id: access.recordId },
data: {
...(patch.moodReminderEnabled !== undefined
? { moodReminderEnabled: patch.moodReminderEnabled }
: {}),
...(notificationPreferences !== undefined
? { notificationPrefs: toJson(notificationPreferences) }
: {}),
},
select: { moodReminderEnabled: true, notificationPrefs: true },
});
const preferences = parseNotificationPrefs(record.notificationPrefs);
settings = {
moodReminderEnabled: record.moodReminderEnabled,
notificationPreferences: {
medication: {
lowStockRunwayDays: preferences.medication.lowStockRunwayDays,
reorderLeadDays: preferences.medication.reorderLeadDays,
},
mood: { reminderHour: preferences.mood.reminderHour },
},
};
changed = Object.keys(patch);
break;
}
case "thresholds": {
const patch = parseManagedRecordSettingsPatch("thresholds", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
select: { thresholdsJson: true },
});
const overrides = {
...((current?.thresholdsJson ?? {}) as ThresholdOverridesJson),
...patch.overrides,
};
const record = await prisma.user.update({
where: { id: access.recordId },
data: { thresholdsJson: toJson(overrides) },
select: { heightCm: true, dateOfBirth: true, gender: true },
});
invalidateUserProfile(access.recordId);
settings = {
overrides,
effective: getAllEffectiveRanges(record, overrides),
};
changed = Object.keys(patch.overrides);
break;
}
case "coach": {
const patch = parseManagedRecordSettingsPatch("coach", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
select: { coachPrefsJson: true },
});
const nextPreferences = patch.preferences
? {
...parseCoachPrefs(current?.coachPrefsJson),
...patch.preferences,
}
: undefined;
const record = await prisma.user.update({
where: { id: access.recordId },
data: {
...(patch.disableCoach !== undefined
? { disableCoach: patch.disableCoach }
: {}),
...(nextPreferences !== undefined
? {
coachPrefsJson: toJson(nextPreferences),
insightsExcludeMetrics: nextPreferences.excludeMetrics,
}
: {}),
},
select: { disableCoach: true, coachPrefsJson: true },
});
const preferences = parseCoachPrefs(record.coachPrefsJson);
settings = {
disableCoach: record.disableCoach,
preferences: {
tone: preferences.tone,
verbosity: preferences.verbosity,
excludeMetrics: preferences.excludeMetrics,
showEvidenceByDefault: preferences.showEvidenceByDefault,
defaultWindow: preferences.defaultWindow,
...(preferences.dataClusters !== undefined
? { dataClusters: preferences.dataClusters }
: {}),
},
};
changed = Object.keys(patch);
break;
}
case "insights": {
const patch = parseManagedRecordSettingsPatch("insights", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
select: { insightsLayoutJson: true },
});
const layout = serializeInsightsLayout(
patch.layout,
resolveInsightsLayout(current?.insightsLayoutJson),
);
await prisma.user.update({
where: { id: access.recordId },
data: { insightsLayoutJson: toJson(layout) },
});
invalidateUserInsightsLayout(access.recordId);
settings = { layout };
changed = ["layout"];
break;
}
}
await auditLog(`record-settings.${family}.update`, {
userId: access.recordId,
actorUserId: access.actorId,
ipAddress: getClientIp(request),
details: { changed },
});
annotate({
action: { name: `record-settings.${family}.update` },
meta: { recordId: access.recordId, changed },
});
return apiSuccess({ recordId: access.recordId, family, settings });
} catch (error) {
if (error instanceof Error && "issues" in error) {
return returnAllZodIssues(error as never, 422);
}
throw error;
}
},
);
@@ -0,0 +1,87 @@
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
resolveManagedIntegrationState,
} from "@/lib/record-settings";
export const dynamic = "force-dynamic";
const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [
"withings",
"whoop",
"fitbit",
"nightscout",
"polar",
"oura",
"google-health",
"strava",
];
/**
* A managed profile may disclose whether its existing integrations are
* connected, but this endpoint deliberately has no control or credential
* projection. OAuth and provider state cannot safely carry a record selector.
*/
export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([
Promise.all(
MANAGED_INTEGRATIONS.map((integration) =>
getIntegrationStatus(access.recordId, integration),
),
),
prisma.withingsConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.whoopConnection.findUnique({
where: { userId: access.recordId },
select: { whoopUserId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
]);
const connected: Record<IntegrationKey, boolean> = {
withings: withings !== null,
whoop: whoop?.whoopUserId != null,
fitbit: fitbit !== null,
"google-health": googleHealth !== null,
nightscout: context.user.nightscoutUrlEncrypted !== null,
polar: context.user.polarAccessTokenEncrypted !== null,
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
};
const integrations = statuses.map((status) => ({
integration: status.integration,
state: resolveManagedIntegrationState(
status.state,
connected,
status.integration,
),
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
}));
annotate({ action: { name: "record-settings.integrations.get" } });
return apiSuccess({ recordId: access.recordId, integrations });
});
+35
View File
@@ -0,0 +1,35 @@
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
toRecordSettingsDto,
} from "@/lib/record-settings";
export const dynamic = "force-dynamic";
/**
* The active managed record's configuration identity. This is intentionally a
* distinct record DTO rather than another actor field on `/api/auth/me`.
*/
export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
annotate({ action: { name: "record-settings.get" } });
return apiSuccess(
toRecordSettingsDto({
id: access.recordId,
name: context.user.displayName ?? context.user.username,
locale: context.user.locale,
timezone: context.user.timezone,
recordKind: "managed",
}),
);
});