mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their stubs. A nightly job (03:20) and the connect backfill read the timeline, visits and residency endpoints through safeFetch and store one row of daily values per local day: distances, places visited, minutes away from the home place and the sealed country with its travel-day verdict. A private instance needs its exact origin in DAWARICH_PRIVATE_ORIGINS. The connection joins the shared ledger and the connection health model, watched for errors only. The day carries a movement block, the model projection drops the travel country, the two movement channels join the correlation matrix under the environment module, travel days become trip suggestions, and the days travel in the environment backup section.
This commit is contained in:
1 parent
1e7fc08f57
commit
b838ed4403
95 files changed
+4466
-158
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,120 @@
|
||||
/**
|
||||
* `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"),
|
||||
undefined as never,
|
||||
)) 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"),
|
||||
undefined as never,
|
||||
)) 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([]) },
|
||||
|
||||
@@ -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([]) },
|
||||
},
|
||||
|
||||
@@ -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