mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Fifty-nine 422s raised off a Zod `safeParse` answered with one opaque sentence and nothing a client could map onto a field, while the other 166 sent `details.issues` — the multi-issue envelope that exists so a caller with three bad fields fixes all three in one round-trip. Twelve of the fifty-nine put a dotted machine token in `error` where the sentence belongs, so `error` was sometimes text to show a person and sometimes a token that must not be shown, with nothing in the payload to tell them apart. Every one of them now answers through `apiValidationError`, which keeps the existing `error` string and the existing `meta` byte for byte and adds the issue list beside them. The dotted tokens keep their place in `error` for the clients that already match on them and are published in `meta.errorCode` as well, which is the field a machine code belongs in and the one a new client should read. A guard reads the route tree per refusal site rather than per file — per file is what let this grow, since a route that used the helper once looked compliant to a `grep -l` sweep — and asserts it still finds the sites it judges, so a matcher that stops matching fails instead of reporting a clean zero.
106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { isP2025 } from "@/lib/prisma-errors";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import {
|
|
apiSuccess,
|
|
apiValidationError,
|
|
safeJson,
|
|
sanitiseZodIssues,
|
|
} from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { markDisconnected } from "@/lib/integrations/status";
|
|
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
|
|
import { NextRequest } from "next/server";
|
|
import { z } from "zod/v4";
|
|
|
|
/**
|
|
* Check whether the user has Fitbit / Google Health BYO-key credentials stored
|
|
* (v1.12.0).
|
|
*/
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "fitbit.credentials.get" } });
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
fitbitClientIdEncrypted: true,
|
|
fitbitClientSecretEncrypted: true,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
hasCredentials:
|
|
!!dbUser?.fitbitClientIdEncrypted &&
|
|
!!dbUser?.fitbitClientSecretEncrypted,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Save Fitbit / Google Health API credentials (encrypted at rest).
|
|
*/
|
|
export const PUT = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "fitbit.credentials.update" } });
|
|
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const result = z.safeParse(fitbitCredentialsSchema, body);
|
|
if (!result.success) {
|
|
return apiValidationError(
|
|
"Client ID and Client Secret are required",
|
|
sanitiseZodIssues(result.error.issues),
|
|
422,
|
|
);
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
fitbitClientIdEncrypted: encrypt(result.data.clientId),
|
|
fitbitClientSecretEncrypted: encrypt(result.data.clientSecret),
|
|
},
|
|
});
|
|
|
|
return apiSuccess({ updated: true });
|
|
});
|
|
|
|
/**
|
|
* Delete Fitbit credentials and the active connection.
|
|
*
|
|
* Audits the teardown and parks the integration ledger at `disconnected` for
|
|
* parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
|
|
* uniform audit trail and the status snapshot does not linger at a stale state.
|
|
*/
|
|
export const DELETE = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "fitbit.credentials.delete" } });
|
|
|
|
// A missing connection row (P2025) is a benign "already disconnected"
|
|
// no-op; any other failure must propagate rather than be swallowed —
|
|
// otherwise the encrypted OAuth tokens can be left orphaned while the user
|
|
// is told the integration was disconnected.
|
|
await prisma.fitbitConnection
|
|
.delete({ where: { userId: user.id } })
|
|
.catch((err) => {
|
|
if (!isP2025(err)) throw err;
|
|
});
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
fitbitClientIdEncrypted: null,
|
|
fitbitClientSecretEncrypted: null,
|
|
},
|
|
});
|
|
await auditLog("fitbit.credentials.delete", { userId: user.id });
|
|
await markDisconnected(user.id, "fitbit");
|
|
|
|
return apiSuccess({ deleted: true });
|
|
});
|