mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'v143/dawarich' into release/v1.43.0
# Conflicts: # src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
commit
da860aeb3e
101 files changed
+5354
-216
No files matched your search
@@ -7,18 +7,54 @@
|
||||
* `private_origin_not_approved`). The key is checked against the instance
|
||||
* before anything is stored, then a 30-day backfill starts. Part of the
|
||||
* `environment` module; own account only, ten attempts a minute.
|
||||
*
|
||||
* Reconnecting to the same instance keeps the home place and country; a
|
||||
* different instance starts without them, because a place id means nothing
|
||||
* on another instance.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
DawarichApiError,
|
||||
DawarichPolicyError,
|
||||
probeDawarich,
|
||||
} from "@/lib/dawarich/client";
|
||||
import {
|
||||
EMPTY_DAWARICH_PREFS,
|
||||
sealDawarichPrefs,
|
||||
} from "@/lib/dawarich/connection";
|
||||
import {
|
||||
configuredDawarichPrivateOrigins,
|
||||
DawarichOriginConfigError,
|
||||
evaluateDawarichUrl,
|
||||
} from "@/lib/dawarich/origin";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import {
|
||||
DAWARICH_CONNECT_BACKFILL_DAYS,
|
||||
enqueueDawarichSync,
|
||||
} from "@/lib/jobs/dawarich-sync";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { INVALID_ORIGIN } from "@/lib/private-origin-policy";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { dawarichConnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.connect" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -40,5 +76,113 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.connect");
|
||||
// A malformed operator list is the operator's to fix: name the entry and
|
||||
// the reason instead of a 500.
|
||||
let privateOrigins: ReadonlySet<string>;
|
||||
try {
|
||||
privateOrigins = configuredDawarichPrivateOrigins();
|
||||
} catch (error) {
|
||||
if (error instanceof DawarichOriginConfigError) {
|
||||
annotate({ meta: { refused: "operator_origin_list_invalid" } });
|
||||
return apiError(error.message, 422, { errorCode: "dawarich.invalid" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The SSRF floor applies to the value that gets stored, not only to the
|
||||
// probe below.
|
||||
const target = evaluateDawarichUrl(parsed.data.url, privateOrigins);
|
||||
if (!target.allowed || !target.baseUrl) {
|
||||
if (target.reasonCode === INVALID_ORIGIN) {
|
||||
return apiError(
|
||||
"Use the address of the Dawarich instance, without credentials or a query",
|
||||
422,
|
||||
{ errorCode: "dawarich.invalid" },
|
||||
);
|
||||
}
|
||||
annotate({ meta: { refused: target.reasonCode } });
|
||||
return apiError(
|
||||
"A Dawarich instance on a private network needs the server operator's approval",
|
||||
422,
|
||||
{ errorCode: "private_origin_not_approved" },
|
||||
);
|
||||
}
|
||||
const baseUrl = target.baseUrl;
|
||||
const apiKey = parsed.data.apiKey;
|
||||
|
||||
try {
|
||||
await probeDawarich({ baseUrl, apiKey });
|
||||
} catch (error) {
|
||||
if (error instanceof DawarichPolicyError) {
|
||||
return apiError(
|
||||
"A Dawarich instance on a private network needs the server operator's approval",
|
||||
422,
|
||||
{ errorCode: "private_origin_not_approved" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof DawarichApiError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
) {
|
||||
return apiError("Dawarich did not accept this API key", 422, {
|
||||
errorCode: "dawarich.credentials_rejected",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
dawarich_probe_status:
|
||||
error instanceof DawarichApiError ? error.status : null,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
"Could not reach the Dawarich instance. Check the address and that it is online.",
|
||||
422,
|
||||
{ errorCode: "dawarich.unreachable" },
|
||||
);
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
|
||||
});
|
||||
const sameInstance =
|
||||
previous?.dawarichUrlEncrypted != null &&
|
||||
decrypt(previous.dawarichUrlEncrypted) === baseUrl;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dawarichUrlEncrypted: encrypt(baseUrl),
|
||||
dawarichApiKeyEncrypted: encrypt(apiKey),
|
||||
dawarichPrefsEncrypted: sameInstance
|
||||
? (previous?.dawarichPrefsEncrypted ?? null)
|
||||
: sealDawarichPrefs(EMPTY_DAWARICH_PREFS),
|
||||
// Display only: the operator list is re-read before every request.
|
||||
dawarichAllowPrivateHost: target.privateOriginApproved,
|
||||
},
|
||||
});
|
||||
await markReconnected(user.id, "dawarich");
|
||||
await auditLog("dawarich.connect", {
|
||||
userId: user.id,
|
||||
details: { privateOrigin: target.privateOriginApproved },
|
||||
});
|
||||
|
||||
// The first month arrives without waiting for the night. Best effort: the
|
||||
// nightly run is the safety net when no worker is bound.
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const outcome = await enqueueDawarichSync({
|
||||
userId: user.id,
|
||||
fromDate: shiftDateKey(
|
||||
yesterdayKey(tz),
|
||||
-(DAWARICH_CONNECT_BACKFILL_DAYS - 1),
|
||||
),
|
||||
}).catch((error: unknown) => {
|
||||
getEvent()?.addWarning(
|
||||
`dawarich backfill enqueue failed: ${error instanceof Error ? error.name : "unknown"}`,
|
||||
);
|
||||
return "failed" as const;
|
||||
});
|
||||
annotate({ meta: { dawarich_backfill: outcome } });
|
||||
|
||||
return apiSuccess({ connected: true as const });
|
||||
});
|
||||
@@ -2,25 +2,32 @@
|
||||
* `POST /api/dawarich/disconnect` — forget the connection; with
|
||||
* `deleteData: true` the stored days go too. Answers
|
||||
* `{ disconnected: true, deletedDays }`. Part of the `environment` module;
|
||||
* own account only.
|
||||
* own account only. Disconnecting twice is harmless.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichDisconnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.disconnect" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
// The body is optional: no body is `{ deleteData: false }`.
|
||||
let body: unknown = {};
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const read = await safeJson(request, { maxBytes: 1024 });
|
||||
if (read.error) return read.error;
|
||||
body = read.data;
|
||||
}
|
||||
const parsed = dawarichDisconnectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
@@ -28,5 +35,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.disconnect");
|
||||
const deletedDays = await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dawarichUrlEncrypted: null,
|
||||
dawarichApiKeyEncrypted: null,
|
||||
dawarichPrefsEncrypted: null,
|
||||
dawarichAllowPrivateHost: false,
|
||||
},
|
||||
});
|
||||
if (!parsed.data.deleteData) return 0;
|
||||
const { count } = await tx.dawarichDay.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return count;
|
||||
});
|
||||
await markDisconnected(user.id, "dawarich");
|
||||
await auditLog("dawarich.disconnect", {
|
||||
userId: user.id,
|
||||
details: { deletedDays },
|
||||
});
|
||||
annotate({ meta: { deleted_days: deletedDays } });
|
||||
|
||||
return apiSuccess({ disconnected: true as const, deletedDays });
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* `GET /api/dawarich/places` answers the instance's places for the home-place
|
||||
* picker and stores none of them. The database is a spy that records every
|
||||
* call: the only one allowed is the read of the connection.
|
||||
*
|
||||
* Mutation check: make the route `prisma.user.update(...)` the places into
|
||||
* the prefs and the write assertion names it.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { calls, fetchPlacesMock } = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
fetchPlacesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (value: string) => value.replace(/^enc:/, ""),
|
||||
decryptBytes: vi.fn(),
|
||||
encryptBytes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/dawarich/client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/dawarich/client")>()),
|
||||
fetchPlaces: fetchPlacesMock,
|
||||
}));
|
||||
|
||||
/** Every delegate call is recorded as `model.method`. */
|
||||
vi.mock("@/lib/db", () => {
|
||||
const delegate = (model: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, method: string) =>
|
||||
vi.fn(async () => {
|
||||
calls.push(`${model}.${method}`);
|
||||
if (model === "user" && method === "findUnique") {
|
||||
return {
|
||||
dawarichUrlEncrypted: "enc:https://dawarich.example.com",
|
||||
dawarichApiKeyEncrypted: "enc:key",
|
||||
dawarichPrefsEncrypted: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, model: string) =>
|
||||
model.startsWith("$")
|
||||
? vi.fn(async () => {
|
||||
calls.push(model);
|
||||
return [];
|
||||
})
|
||||
: delegate(model),
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const WRITE =
|
||||
/\.(create|createMany|update|updateMany|upsert|delete|deleteMany)$|^\$(executeRaw|executeRawUnsafe|transaction)/;
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
fetchPlacesMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/dawarich/places", () => {
|
||||
it("answers the places and writes none of them anywhere", async () => {
|
||||
fetchPlacesMock.mockResolvedValue([
|
||||
{ id: 7, name: "Home" },
|
||||
{ id: 9, name: "Office" },
|
||||
]);
|
||||
const res = (await GET(
|
||||
new NextRequest("http://localhost/api/dawarich/places?q=o"),
|
||||
)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.places).toEqual([
|
||||
{ id: 7, name: "Home" },
|
||||
{ id: 9, name: "Office" },
|
||||
]);
|
||||
expect(fetchPlacesMock).toHaveBeenCalledWith(
|
||||
{ baseUrl: "https://dawarich.example.com", apiKey: "key" },
|
||||
"o",
|
||||
);
|
||||
expect(calls).toEqual(["user.findUnique"]);
|
||||
expect(calls.filter((c) => WRITE.test(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("says the instance is unreachable instead of failing", async () => {
|
||||
fetchPlacesMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = (await GET(
|
||||
new NextRequest("http://localhost/api/dawarich/places"),
|
||||
)) as Response;
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).meta.errorCode).toBe("dawarich.unreachable");
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,24 @@
|
||||
* `GET /api/dawarich/places?q=` — the instance's places, to pick the home
|
||||
* place from. Answered from Dawarich live (at most 200, filtered by `q`) and
|
||||
* never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`.
|
||||
* Part of the `environment` module; own account only.
|
||||
* Part of the `environment` module; own account only, thirty reads a minute.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { fetchPlaces } from "@/lib/dawarich/client";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.places" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -28,5 +32,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.places.get");
|
||||
const rl = await checkRateLimit(`dawarich-places:${user.id}`, 30, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429, {
|
||||
errorCode: "dawarich.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const stored = await loadDawarichConnection(user.id);
|
||||
if (!stored) {
|
||||
return apiError("Dawarich is not connected", 409, {
|
||||
errorCode: "dawarich.not_connected",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const places = await fetchPlaces(stored.connection, parsed.data.q);
|
||||
annotate({ meta: { places: places.length } });
|
||||
return apiSuccess({ places });
|
||||
} catch {
|
||||
return apiError("Could not reach the Dawarich instance", 502, {
|
||||
errorCode: "dawarich.unreachable",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2,18 +2,45 @@
|
||||
* `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich
|
||||
* instance) and the home country (ISO 3166-1 alpha-2) the daily values are
|
||||
* measured against. 422 `dawarich.home_place_unknown` for a place id the
|
||||
* instance does not have. Part of the `environment` module; own account only.
|
||||
* instance does not have; 409 `dawarich.not_connected`; 502
|
||||
* `dawarich.unreachable` when the place cannot be checked. Part of the
|
||||
* `environment` module; own account only.
|
||||
*
|
||||
* A new home country re-judges the stored travel days at once. A new home
|
||||
* place needs the visits again, so the stored days are fetched again in the
|
||||
* background (up to a year).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { placeExists } from "@/lib/dawarich/client";
|
||||
import {
|
||||
effectiveHomeCountry,
|
||||
loadDawarichConnection,
|
||||
saveDawarichPrefs,
|
||||
} from "@/lib/dawarich/connection";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { rejudgeTravelDays } from "@/lib/dawarich/travel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueDawarichSync } from "@/lib/jobs/dawarich-sync";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSettingsSchema } from "@/lib/validations/dawarich";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
DAWARICH_SYNC_MAX_DAYS,
|
||||
dawarichSettingsSchema,
|
||||
} from "@/lib/validations/dawarich";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.settings.update" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -28,5 +55,70 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.settings.patch");
|
||||
const stored = await loadDawarichConnection(user.id);
|
||||
if (!stored) {
|
||||
return apiError("Dawarich is not connected", 409, {
|
||||
errorCode: "dawarich.not_connected",
|
||||
});
|
||||
}
|
||||
const { prefs } = stored;
|
||||
const { homePlaceId, homeCountry } = parsed.data;
|
||||
|
||||
if (homePlaceId != null && homePlaceId !== prefs.homePlaceId) {
|
||||
let known: boolean;
|
||||
try {
|
||||
known = await placeExists(stored.connection, homePlaceId);
|
||||
} catch {
|
||||
return apiError("Could not reach the Dawarich instance", 502, {
|
||||
errorCode: "dawarich.unreachable",
|
||||
});
|
||||
}
|
||||
if (!known) {
|
||||
return apiError("The Dawarich instance has no such place", 422, {
|
||||
errorCode: "dawarich.home_place_unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const next = {
|
||||
...prefs,
|
||||
homePlaceId: homePlaceId === undefined ? prefs.homePlaceId : homePlaceId,
|
||||
homeCountry: homeCountry === undefined ? prefs.homeCountry : homeCountry,
|
||||
};
|
||||
await saveDawarichPrefs(user.id, next);
|
||||
|
||||
const countryChanged =
|
||||
effectiveHomeCountry(next) !== effectiveHomeCountry(prefs);
|
||||
if (countryChanged) {
|
||||
await rejudgeTravelDays(user.id, effectiveHomeCountry(next));
|
||||
}
|
||||
let refetch = "none";
|
||||
if (next.homePlaceId !== prefs.homePlaceId) {
|
||||
const oldest = await prisma.dawarichDay.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "asc" },
|
||||
select: { date: true },
|
||||
});
|
||||
if (oldest) {
|
||||
const yesterday = yesterdayKey(await resolveUserTimezone(user.id));
|
||||
const floor = shiftDateKey(yesterday, -(DAWARICH_SYNC_MAX_DAYS - 1));
|
||||
refetch = await enqueueDawarichSync({
|
||||
userId: user.id,
|
||||
fromDate: oldest.date > floor ? oldest.date : floor,
|
||||
});
|
||||
}
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
home_place_set: next.homePlaceId !== null,
|
||||
home_country_set: next.homeCountry !== null,
|
||||
travel_rejudged: countryChanged,
|
||||
refetch,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
homePlaceId: next.homePlaceId,
|
||||
homeCountry: next.homeCountry,
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,17 @@
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { loadDawarichStatus } from "@/lib/dawarich/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.status" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.status.get");
|
||||
return apiSuccess(await loadDawarichStatus(user.id));
|
||||
});
|
||||
@@ -1,26 +1,44 @@
|
||||
/**
|
||||
* `POST /api/dawarich/sync` — fetch the daily values again from `fromDate`
|
||||
* (at most 365 days back; default: the nightly window). 202
|
||||
* `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment`
|
||||
* module; own account only.
|
||||
* (at most 365 days back, ending yesterday; default: the nightly window).
|
||||
* 202 `{ enqueued }`: false when a run for this account is already queued or
|
||||
* no worker is bound. 409 `dawarich.not_connected`; 422 `dawarich.invalid`.
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
import { enqueueDawarichSync } from "@/lib/jobs/dawarich-sync";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSyncSchema } from "@/lib/validations/dawarich";
|
||||
import { daysBetweenDateKeys } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
DAWARICH_SYNC_MAX_DAYS,
|
||||
dawarichSyncSchema,
|
||||
} from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.sync.request" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
// The body is optional: no body is the nightly window.
|
||||
let body: unknown = {};
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const read = await safeJson(request, { maxBytes: 1024 });
|
||||
if (read.error) return read.error;
|
||||
body = read.data;
|
||||
}
|
||||
const parsed = dawarichSyncSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
@@ -28,5 +46,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.sync");
|
||||
if (!(await loadDawarichConnection(user.id))) {
|
||||
return apiError("Dawarich is not connected", 409, {
|
||||
errorCode: "dawarich.not_connected",
|
||||
});
|
||||
}
|
||||
|
||||
const { fromDate } = parsed.data;
|
||||
if (fromDate !== undefined) {
|
||||
const yesterday = yesterdayKey(await resolveUserTimezone(user.id));
|
||||
const span = daysBetweenDateKeys(fromDate, yesterday) + 1;
|
||||
if (!Number.isFinite(span) || span < 1 || span > DAWARICH_SYNC_MAX_DAYS) {
|
||||
return apiError(
|
||||
`fromDate must lie within the ${DAWARICH_SYNC_MAX_DAYS} days ending yesterday`,
|
||||
422,
|
||||
{ errorCode: "dawarich.invalid" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = await enqueueDawarichSync({ userId: user.id, fromDate });
|
||||
annotate({ meta: { dawarich_sync: outcome } });
|
||||
return apiSuccess({ enqueued: outcome === "enqueued" }, 202);
|
||||
});
|
||||
@@ -1,15 +1,91 @@
|
||||
/**
|
||||
* `POST /api/dawarich/test` — probe the connected Dawarich instance and answer
|
||||
* the shared integration probe shape. Part of the `environment` module; own
|
||||
* account only.
|
||||
* the shared integration probe shape. A failed probe is a 502 whose
|
||||
* `errorCode` is one of the shared probe classes (`credentials_rejected`,
|
||||
* `rate_limited`, `upstream_error`, `timeout`, `url_not_public`,
|
||||
* `connection_failed`); 422 `not_configured` without a connection. Part of the
|
||||
* `environment` module; own account only, five probes a minute.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
DawarichApiError,
|
||||
DawarichPolicyError,
|
||||
probeDawarich,
|
||||
} from "@/lib/dawarich/client";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function probeClass(error: unknown): { code: string; message: string } {
|
||||
if (error instanceof DawarichPolicyError) {
|
||||
return {
|
||||
code: "url_not_public",
|
||||
message:
|
||||
"The instance is on a private network the operator has not approved",
|
||||
};
|
||||
}
|
||||
if (error instanceof DawarichApiError) {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Dawarich rejected the API key",
|
||||
};
|
||||
}
|
||||
if (error.status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Dawarich rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (error.status !== null && error.status >= 500) {
|
||||
return { code: "upstream_error", message: "Dawarich returned an error" };
|
||||
}
|
||||
}
|
||||
if (error instanceof SafeFetchError && error.kind === "timeout") {
|
||||
return { code: "timeout", message: "Dawarich request timed out" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Dawarich connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dawarich.test" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.test");
|
||||
|
||||
const rl = await checkRateLimit(`dawarich-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const stored = await loadDawarichConnection(user.id);
|
||||
if (!stored) {
|
||||
return apiError("Dawarich not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await probeDawarich(stored.connection, TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
const probe = probeClass(error);
|
||||
annotate({ meta: { dawarich_test_code: probe.code } });
|
||||
return apiError(probe.message, 502, { errorCode: probe.code });
|
||||
}
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const ledger = await getIntegrationStatus(user.id, "dawarich");
|
||||
return apiSuccess({
|
||||
ok: true as const,
|
||||
lastSyncedAt: ledger.lastSuccessAt,
|
||||
latencyMs,
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,56 @@
|
||||
/**
|
||||
* `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest
|
||||
* this run of travel days again. Answers `{ dismissed: true }`; 404
|
||||
* `environment.travel_suggestion.not_found`. Part of the `environment`
|
||||
* module; own account only.
|
||||
* `environment.travel_suggestion.not_found` for an id that is not an open
|
||||
* suggestion. Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
openDawarichPrefs,
|
||||
saveDawarichPrefs,
|
||||
} from "@/lib/dawarich/connection";
|
||||
import { listTravelSuggestions } from "@/lib/dawarich/travel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "environment.travel_suggestion.dismiss" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestion.dismiss");
|
||||
|
||||
const notFound = () =>
|
||||
apiError("No such travel suggestion", 404, {
|
||||
errorCode: "environment.travel_suggestion.not_found",
|
||||
});
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
|
||||
});
|
||||
if (!row?.dawarichUrlEncrypted || id.length > 64) return notFound();
|
||||
const prefs = openDawarichPrefs(row.dawarichPrefsEncrypted);
|
||||
const today = userDayKey(new Date(), await resolveUserTimezone(user.id));
|
||||
const open = await listTravelSuggestions(
|
||||
user.id,
|
||||
today,
|
||||
new Set(prefs.dismissedSuggestionIds),
|
||||
);
|
||||
if (!open.some((s) => s.id === id)) return notFound();
|
||||
|
||||
await saveDawarichPrefs(user.id, {
|
||||
...prefs,
|
||||
dismissedSuggestionIds: [...prefs.dismissedSuggestionIds, id],
|
||||
});
|
||||
return apiSuccess({ dismissed: true as const });
|
||||
},
|
||||
);
|
||||
@@ -3,17 +3,39 @@
|
||||
* travel days no stored trip covers yet, as `{ id, startDate, endDate,
|
||||
* countryCode }`. Accepting one goes through `POST /api/environment/travel`
|
||||
* (the person picks the place); dismissing through the sibling route. Part of
|
||||
* the `environment` module; own account only.
|
||||
* the `environment` module; own account only. Without a Dawarich connection
|
||||
* the list is empty.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { openDawarichPrefs } from "@/lib/dawarich/connection";
|
||||
import { listTravelSuggestions } from "@/lib/dawarich/travel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "environment.travel_suggestions.list" } });
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestions.get");
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
|
||||
});
|
||||
if (!row?.dawarichUrlEncrypted) return apiSuccess({ suggestions: [] });
|
||||
const prefs = openDawarichPrefs(row.dawarichPrefsEncrypted);
|
||||
const today = userDayKey(new Date(), await resolveUserTimezone(user.id));
|
||||
const suggestions = await listTravelSuggestions(
|
||||
user.id,
|
||||
today,
|
||||
new Set(prefs.dismissedSuggestionIds),
|
||||
);
|
||||
annotate({ meta: { suggestions: suggestions.length } });
|
||||
return apiSuccess({ suggestions });
|
||||
});
|
||||
@@ -62,6 +62,7 @@ vi.mock("@/lib/db", () => ({
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
@@ -215,6 +216,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -86,6 +86,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// channels in (read from EnvironmentContext); default empty so no env
|
||||
// channel produces a point.
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
@@ -159,6 +160,7 @@ beforeEach(() => {
|
||||
).mockResolvedValue([]);
|
||||
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
expect(envelope.metricFreshnessDegraded).toBe(true);
|
||||
// Still fail-soft: the rest of the envelope survives, all eight entries
|
||||
// present, because a freshness hiccup is never worth the settings surface.
|
||||
expect(envelope.integrations).toHaveLength(8);
|
||||
expect(envelope.integrations).toHaveLength(9);
|
||||
for (const entry of envelope.integrations) {
|
||||
expect(entry.metricFreshness).toEqual([]);
|
||||
}
|
||||
@@ -469,6 +469,7 @@ describe("/api/integrations/status — the unified connection health", () => {
|
||||
oura: ouraHealth,
|
||||
strava: null,
|
||||
nightscout: null,
|
||||
dawarich: null,
|
||||
"apple-health": null,
|
||||
} as never);
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
@@ -69,6 +69,7 @@ export const GET = apiHandler(async () => {
|
||||
ouraStatus,
|
||||
stravaStatus,
|
||||
nightscoutStatus,
|
||||
dawarichStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
@@ -89,6 +90,7 @@ export const GET = apiHandler(async () => {
|
||||
getIntegrationStatus(user.id, "oura"),
|
||||
getIntegrationStatus(user.id, "strava"),
|
||||
getIntegrationStatus(user.id, "nightscout"),
|
||||
getIntegrationStatus(user.id, "dawarich"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -115,6 +117,7 @@ export const GET = apiHandler(async () => {
|
||||
nightscoutUrlEncrypted: true,
|
||||
nightscoutTokenEncrypted: true,
|
||||
nightscoutAllowPrivateHost: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
@@ -173,6 +176,7 @@ export const GET = apiHandler(async () => {
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
]);
|
||||
|
||||
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
|
||||
@@ -283,6 +287,7 @@ export const GET = apiHandler(async () => {
|
||||
const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted;
|
||||
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
|
||||
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
|
||||
|
||||
if (metricFreshnessDegraded) {
|
||||
annotate({ meta: { integrations_metric_freshness_degraded: true } });
|
||||
@@ -460,6 +465,18 @@ export const GET = apiHandler(async () => {
|
||||
configured: nightscoutConfigured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & NightscoutExtras,
|
||||
{
|
||||
...publish(dawarichStatus),
|
||||
// v1.43 — Dawarich, read by the card through `health`. The ledger core
|
||||
// rides along like on every entry, and goes with the rest in v1.44.
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
health: health["dawarich"],
|
||||
...resolve("dawarich", dawarichStatus, {
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & DawarichExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -560,3 +577,10 @@ interface NightscoutExtras {
|
||||
hasToken: boolean;
|
||||
allowPrivateHost: boolean;
|
||||
}
|
||||
|
||||
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
|
||||
// own status route.
|
||||
interface DawarichExtras {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
@@ -70,6 +70,9 @@ export const GET = apiHandler(async () => {
|
||||
polar: context.user.polarAccessTokenEncrypted !== null,
|
||||
oura: context.user.ouraAccessTokenEncrypted !== null,
|
||||
strava: context.user.stravaAccessTokenEncrypted !== null,
|
||||
// Not offered on a managed profile (no managed integrations before
|
||||
// v1.44); listed so the map stays total.
|
||||
dawarich: false,
|
||||
};
|
||||
// The one server-resolved state per connection, from the same loader the
|
||||
// owner's own envelope uses, so a guardian reads the record's connections in
|
||||
|
||||
Reference in new issue
Block a user