Merge branch 'v143/dawarich' into release/v1.43.0

# Conflicts:
#	src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 02:51:02 +02:00
commit da860aeb3e
101 files changed
+5354 -216

No files matched your search

+147 -3
View File
@@ -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 });
});
+38 -8
View File
@@ -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");
});
});
+30 -4
View File
@@ -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",
});
}
});
+97 -5
View File
@@ -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 -2
View File
@@ -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));
});
+50 -11
View File
@@ -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);
});
+80 -4
View File
@@ -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" });
+24
View File
@@ -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