feat(backend): add intake journal APIs and share note support
This commit is contained in:
@@ -76,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
|
||||
];
|
||||
|
||||
for (const sql of alterMigrations) {
|
||||
@@ -97,6 +98,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS intake_journal (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
scheduled_for INTEGER NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -164,6 +175,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
const createIndexMigrations = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||
|
||||
@@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] {
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
|
||||
@@ -180,6 +180,7 @@ export const shareTokens = sqliteTable("share_tokens", {
|
||||
token: text("token", { length: 64 }).notNull().unique(),
|
||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||
});
|
||||
@@ -236,6 +237,27 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Intake Journal - Optional owner-scoped note for a tracked dose event
|
||||
// =============================================================================
|
||||
export const intakeJournal = sqliteTable("intake_journal", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
doseTrackingId: integer("dose_tracking_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => doseTracking.id, { onDelete: "cascade" }),
|
||||
medicationId: integer("medication_id")
|
||||
.notNull()
|
||||
.references(() => medications.id, { onDelete: "cascade" }),
|
||||
scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
|
||||
note: text("note").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Refill History - Tracks when medication stock was refilled
|
||||
// =============================================================================
|
||||
|
||||
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { intakeJournalRoutes } from "./routes/intake-journal.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||
@@ -109,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
{ name: "health", description: "Service health endpoints" },
|
||||
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||
{ name: "api-keys", description: "Programmatic API key management" },
|
||||
{ name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
|
||||
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
||||
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||
],
|
||||
@@ -248,6 +250,7 @@ export async function createApp(options?: {
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
@@ -349,6 +352,7 @@ await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
+566
-36
@@ -1,19 +1,26 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
|
||||
import {
|
||||
getIntakeJournalForDoseEvent,
|
||||
resolveTrackedDoseEventForUser,
|
||||
upsertIntakeJournalForDoseEvent,
|
||||
} from "../services/intake-journal-service.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
tokenParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { redactTokenForLog } from "../utils/redaction.js";
|
||||
import {
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
@@ -32,6 +39,10 @@ const shareDoseSchema = z.object({
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
});
|
||||
|
||||
const shareJournalUpsertSchema = z.object({
|
||||
note: z.string().max(4000),
|
||||
});
|
||||
|
||||
const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
@@ -56,12 +67,52 @@ const doseReadResponseSchema = {
|
||||
markedBy: { type: ["string", "null"] },
|
||||
takenSource: { type: "string" },
|
||||
dismissed: { type: "boolean" },
|
||||
hasJournalNote: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareJournalEntrySchema = {
|
||||
type: "object",
|
||||
required: [
|
||||
"doseTrackingId",
|
||||
"doseId",
|
||||
"medicationId",
|
||||
"medicationName",
|
||||
"scheduledFor",
|
||||
"dismissed",
|
||||
"takenSource",
|
||||
"note",
|
||||
"updatedAt",
|
||||
],
|
||||
properties: {
|
||||
doseTrackingId: { type: "integer" },
|
||||
doseId: { type: "string" },
|
||||
medicationId: { type: "integer" },
|
||||
medicationName: { type: "string" },
|
||||
scheduledFor: { type: "string", format: "date-time" },
|
||||
takenAt: { type: ["string", "null"], format: "date-time" },
|
||||
dismissed: { type: "boolean" },
|
||||
takenSource: { type: "string", enum: ["manual", "automatic"] },
|
||||
markedBy: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const shareJournalResponseSchema = {
|
||||
type: "object",
|
||||
required: ["entry"],
|
||||
properties: {
|
||||
entry: shareJournalEntrySchema,
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
function getValidationErrorMessage(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
if (!firstIssue) {
|
||||
@@ -71,6 +122,18 @@ function getValidationErrorMessage(error: z.ZodError): string {
|
||||
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
|
||||
}
|
||||
|
||||
function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dismissed && value.getTime() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -135,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
@@ -172,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
||||
return expectedPersons.includes(parsedDose.personSuffix);
|
||||
}
|
||||
|
||||
function getLocalDayStartMs(value: Date | number): number {
|
||||
const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
|
||||
const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
|
||||
const todayStart = getLocalDayStartMs(new Date());
|
||||
const earliestVisible = new Date(todayStart);
|
||||
earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
|
||||
const latestVisibleExclusive = new Date(todayStart);
|
||||
latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
|
||||
const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
|
||||
|
||||
return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
|
||||
}
|
||||
|
||||
async function isDoseOutOfStock(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
@@ -226,6 +311,81 @@ async function isDoseOutOfStock(options: {
|
||||
);
|
||||
}
|
||||
|
||||
async function markDoseSkippedForUser(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
}): Promise<"created" | "updated" | "already_skipped"> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||
|
||||
if (existing) {
|
||||
if (existing.dismissed) {
|
||||
return "already_skipped";
|
||||
}
|
||||
|
||||
await db
|
||||
.update(doseTracking)
|
||||
.set({ dismissed: true })
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||
return "updated";
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId: input.doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
|
||||
return "created";
|
||||
}
|
||||
|
||||
async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise<boolean> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||
|
||||
if (!existing?.dismissed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRealTakenTimestamp =
|
||||
existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt);
|
||||
if (existing.markedBy !== null || hasRealTakenTimestamp) {
|
||||
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
await db.delete(doseTracking).where(eq(doseTracking.id, existing.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSharedJournalEntryDto(input: {
|
||||
event: NonNullable<Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>>>;
|
||||
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
|
||||
}) {
|
||||
const { event, journalEntry } = input;
|
||||
|
||||
return {
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
doseId: event.doseId,
|
||||
medicationId: event.medicationId,
|
||||
medicationName: event.medicationName,
|
||||
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
|
||||
takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed),
|
||||
dismissed: event.dismissed,
|
||||
takenSource: event.takenSource,
|
||||
markedBy: event.markedBy,
|
||||
note: journalEntry?.note ?? null,
|
||||
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
|
||||
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking Routes
|
||||
// =============================================================================
|
||||
@@ -233,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
applyOpenApiRouteStandards(app, {
|
||||
tag: "doses",
|
||||
protectedByDefault: false,
|
||||
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
|
||||
protectedPaths: [
|
||||
/^\/doses\/taken$/,
|
||||
/^\/doses\/taken\/:doseId$/,
|
||||
/^\/doses\/dismiss$/,
|
||||
/^\/doses\/skip$/,
|
||||
/^\/doses\/skip\/:doseId$/,
|
||||
],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -383,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/skip - PROTECTED: Mark a single dose as skipped
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
||||
"/doses/skip",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const parsed = markDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId });
|
||||
if (status === "already_skipped") {
|
||||
return { success: true, message: "Already skipped" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { doseId: string } }>(
|
||||
"/doses/skip/:doseId",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
await undoDoseSkippedForUser({ userId, doseId: request.params.doseId });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -431,27 +674,8 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
// becomes dismissed, regardless of whether it already has a taken timestamp.
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
if (!existing.dismissed) {
|
||||
await db
|
||||
.update(doseTracking)
|
||||
.set({ dismissed: true })
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
const status = await markDoseSkippedForUser({ userId, doseId });
|
||||
if (status !== "already_skipped") {
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
@@ -533,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
|
||||
request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
// Keep public dose reads scoped to the selected share person and visible schedule window.
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||
const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
|
||||
for (const dose of doses) {
|
||||
if (await validateShareDoseId(share, dose.doseId)) {
|
||||
visibleDoses.push(dose);
|
||||
}
|
||||
}
|
||||
|
||||
const journalDoseTrackingIds = new Set<number>();
|
||||
if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
|
||||
const journalRows = await db
|
||||
.select({ doseTrackingId: intakeJournal.doseTrackingId })
|
||||
.from(intakeJournal)
|
||||
.where(
|
||||
and(
|
||||
eq(intakeJournal.userId, share.userId),
|
||||
inArray(
|
||||
intakeJournal.doseTrackingId,
|
||||
visibleDoses.map((dose) => dose.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const row of journalRows) {
|
||||
journalDoseTrackingIds.add(row.doseTrackingId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doses: visibleDoses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
hasJournalNote: journalDoseTrackingIds.has(d.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: shareJournalResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
403: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
if (!(share.allowJournalNotes ?? false)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
|
||||
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { token: string; doseId: string }; Body: z.infer<typeof shareJournalUpsertSchema> }>(
|
||||
"/share/:token/journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["note"],
|
||||
properties: {
|
||||
note: { type: "string", maxLength: 4000 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
response: {
|
||||
200: shareJournalResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
403: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const parsed = shareJournalUpsertSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
const normalizedNote = parsed.data.note.trim();
|
||||
if (normalizedNote.length === 0) {
|
||||
return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
|
||||
}
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
if (!(share.allowJournalNotes ?? false)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await upsertIntakeJournalForDoseEvent({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
note: normalizedNote,
|
||||
});
|
||||
|
||||
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
403: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
if (!(share.allowJournalNotes ?? false)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||
}
|
||||
|
||||
return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||
"/share/:token/doses/skip",
|
||||
{
|
||||
schema: {
|
||||
params: tokenParamsSchema,
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
|
||||
if (status === "already_skipped") {
|
||||
return { success: true, message: "Already skipped" };
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/doses/skip/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||
400: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
await undoDoseSkippedForUser({ userId: share.userId, doseId });
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -582,6 +1110,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -594,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
@@ -614,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
if (existing) {
|
||||
request.log.debug(
|
||||
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
@@ -627,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
});
|
||||
if (outOfStock) {
|
||||
request.log.info(
|
||||
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
@@ -644,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||
`[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
@@ -675,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
@@ -699,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
request.log.debug(
|
||||
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
@@ -707,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
.delete(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
request.log.info(
|
||||
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+372
-176
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/path-utils.js";
|
||||
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import {
|
||||
listIntakeJournalExportPayloadsForUser,
|
||||
restoreIntakeJournalForImportedDose,
|
||||
} from "../services/intake-journal-export.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.5";
|
||||
const EXPORT_VERSION = "1.6";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -91,6 +95,9 @@ const doseHistorySchema = z.object({
|
||||
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||
dismissed: z.boolean().default(false),
|
||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||
journalNote: z.string().nullable().optional(),
|
||||
journalCreatedAt: z.string().nullable().optional(),
|
||||
journalUpdatedAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const refillHistoryExportSchema = z.object({
|
||||
@@ -105,6 +112,7 @@ const refillHistoryExportSchema = z.object({
|
||||
const shareLinkSchema = z.object({
|
||||
takenBy: z.string().min(1),
|
||||
scheduleDays: z.number().int().min(1).default(30),
|
||||
allowJournalNotes: z.boolean().default(false),
|
||||
expiresAt: z.string().nullable().optional(), // ISO datetime
|
||||
regenerateToken: z.boolean().default(true),
|
||||
});
|
||||
@@ -195,7 +203,7 @@ const importBodyOpenApiSchema = {
|
||||
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
example: {
|
||||
version: "1.8.0",
|
||||
version: "1.6",
|
||||
exportedAt: "2026-03-11T10:15:00.000Z",
|
||||
includeSensitiveData: true,
|
||||
medications: [
|
||||
@@ -215,13 +223,72 @@ const importBodyOpenApiSchema = {
|
||||
],
|
||||
},
|
||||
],
|
||||
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
||||
doseHistory: [
|
||||
{
|
||||
medicationRef: "med-1",
|
||||
scheduleIndex: 0,
|
||||
scheduledTime: "2026-03-11T08:00:00.000Z",
|
||||
takenAt: "2026-03-11T08:03:00.000Z",
|
||||
markedBy: "Daniel",
|
||||
takenSource: "manual",
|
||||
dismissed: false,
|
||||
takenByPerson: "Daniel",
|
||||
journalNote: "Took after breakfast.",
|
||||
journalUpdatedAt: "2026-03-11T08:05:00.000Z",
|
||||
},
|
||||
],
|
||||
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
|
||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const importPreviewResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
version: { type: "string" },
|
||||
exportedAt: { type: "string", format: "date-time" },
|
||||
includeSensitiveData: { type: "boolean" },
|
||||
incoming: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medications: { type: "integer" },
|
||||
doseHistory: { type: "integer" },
|
||||
refillHistory: { type: "integer" },
|
||||
shareLinks: { type: "integer" },
|
||||
journalEntries: { type: "integer" },
|
||||
imageCount: { type: "integer" },
|
||||
hasSettings: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
current: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medications: { type: "integer" },
|
||||
doseHistory: { type: "integer" },
|
||||
refillHistory: { type: "integer" },
|
||||
shareLinks: { type: "integer" },
|
||||
hasSettings: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
warnings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
replacesExistingData: { type: "boolean" },
|
||||
regeneratesShareLinks: { type: "boolean" },
|
||||
containsImages: { type: "boolean" },
|
||||
containsSensitiveData: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
@@ -321,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function removeFileIfPresent(filePath: string): string | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Unknown file removal error";
|
||||
}
|
||||
}
|
||||
|
||||
function buildImportPreview(
|
||||
importData: z.infer<typeof importDataSchema>,
|
||||
currentData: {
|
||||
medications: number;
|
||||
doseHistory: number;
|
||||
refillHistory: number;
|
||||
shareLinks: number;
|
||||
hasSettings: boolean;
|
||||
}
|
||||
) {
|
||||
const journalEntries = importData.doseHistory.filter(
|
||||
(dose) => typeof dose.journalNote === "string" && dose.journalNote.trim()
|
||||
).length;
|
||||
const imageCount = importData.medications.filter(
|
||||
(med) => typeof med.image === "string" && med.image.startsWith("data:")
|
||||
).length;
|
||||
|
||||
return {
|
||||
version: importData.version,
|
||||
exportedAt: importData.exportedAt,
|
||||
includeSensitiveData: importData.includeSensitiveData,
|
||||
incoming: {
|
||||
medications: importData.medications.length,
|
||||
doseHistory: importData.doseHistory.length,
|
||||
refillHistory: importData.refillHistory.length,
|
||||
shareLinks: importData.shareLinks.length,
|
||||
journalEntries,
|
||||
imageCount,
|
||||
hasSettings: Boolean(importData.settings),
|
||||
},
|
||||
current: currentData,
|
||||
warnings: {
|
||||
replacesExistingData:
|
||||
currentData.medications > 0 ||
|
||||
currentData.doseHistory > 0 ||
|
||||
currentData.refillHistory > 0 ||
|
||||
currentData.shareLinks > 0 ||
|
||||
currentData.hasSettings,
|
||||
regeneratesShareLinks: importData.shareLinks.length > 0,
|
||||
containsImages: imageCount > 0,
|
||||
containsSensitiveData: importData.includeSensitiveData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Parse dose ID to extract medication ID and timestamp
|
||||
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||
function parseDoseId(
|
||||
@@ -442,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
// 2. Load all dose tracking entries
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
|
||||
|
||||
const exportDoseHistory = doses
|
||||
.map((dose) => {
|
||||
@@ -484,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
takenByPerson: parsed.person,
|
||||
...journalPayloadsByDoseTrackingId.get(dose.id),
|
||||
};
|
||||
})
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
@@ -542,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
expiresAt: expiresAtIso,
|
||||
regenerateToken: true, // Always regenerate tokens on import for security
|
||||
};
|
||||
@@ -617,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /import/preview - Validate and summarize import data without writing
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post(
|
||||
"/import/preview",
|
||||
{
|
||||
config: {
|
||||
rawBody: true,
|
||||
},
|
||||
bodyLimit: 50 * 1024 * 1024,
|
||||
schema: {
|
||||
body: importBodyOpenApiSchema,
|
||||
response: {
|
||||
200: importPreviewResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = importDataSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid import data format",
|
||||
details: parsed.error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] =
|
||||
await Promise.all([
|
||||
db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)),
|
||||
db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)),
|
||||
db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)),
|
||||
db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)),
|
||||
db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preview: buildImportPreview(parsed.data, {
|
||||
medications: existingMeds.length,
|
||||
doseHistory: existingDoseHistory.length,
|
||||
refillHistory: existingRefillHistory.length,
|
||||
shareLinks: existingShareLinks.length,
|
||||
hasSettings: existingSettings.length > 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /import - Import user data (replaces all existing data!)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -649,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
500: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -666,193 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
const importData = parsed.data;
|
||||
|
||||
// 2. Delete all existing user data (in correct order to respect foreign keys)
|
||||
// Note: CASCADE delete should handle this, but let's be explicit
|
||||
|
||||
// First, delete images for existing medications
|
||||
// Existing image files are removed only after the DB import commits.
|
||||
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
for (const med of existingMeds) {
|
||||
if (med.imageUrl) {
|
||||
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
|
||||
if (existsSync(imagePath)) {
|
||||
try {
|
||||
unlinkSync(imagePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
const oldImagePaths = existingMeds
|
||||
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
|
||||
.filter((path): path is string => path !== null);
|
||||
const newImagePaths: string[] = [];
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
|
||||
await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
|
||||
await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await tx.delete(medications).where(eq(medications.userId, userId));
|
||||
await tx.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(medications)
|
||||
.values({
|
||||
userId,
|
||||
name: med.name,
|
||||
genericName: med.genericName || null,
|
||||
takenByJson,
|
||||
medicationForm: med.medicationForm ?? "tablet",
|
||||
pillForm: med.pillForm || null,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: normalizePackageType(med.inventory.packageType),
|
||||
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||
packCount: med.inventory.packCount,
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
totalPills: med.inventory.totalPills ?? null,
|
||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
medicationEndDate: med.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled
|
||||
? (med.prescriptionAuthorizedRefills ?? null)
|
||||
: null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled
|
||||
? (med.prescriptionRemainingRefills ?? null)
|
||||
: null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||
dismissedUntil: med.dismissedUntil || null,
|
||||
imageUrl: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
exportIdToNewId.set(med._exportId, inserted.id);
|
||||
|
||||
if (med.image) {
|
||||
const imageUrl = base64ToImage(med.image, inserted.id);
|
||||
if (imageUrl) {
|
||||
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
|
||||
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in order: refill history, doses, share tokens, medications, settings
|
||||
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await db.delete(medications).where(eq(medications.userId, userId));
|
||||
await db.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
for (const dose of importData.doseHistory) {
|
||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||
if (!newMedId) continue;
|
||||
|
||||
// 3. Import medications and build ID mapping
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
const scheduledFor = new Date(dose.scheduledTime);
|
||||
const timestampMs = scheduledFor.getTime();
|
||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||
|
||||
for (const med of importData.medications) {
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
const [insertedDose] = await tx
|
||||
.insert(doseTracking)
|
||||
.values({
|
||||
userId,
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
})
|
||||
.returning({ id: doseTracking.id });
|
||||
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
await restoreIntakeJournalForImportedDose({
|
||||
userId,
|
||||
doseTrackingId: insertedDose.id,
|
||||
medicationId: newMedId,
|
||||
scheduledFor,
|
||||
journalNote: dose.journalNote,
|
||||
journalCreatedAt: dose.journalCreatedAt,
|
||||
journalUpdatedAt: dose.journalUpdatedAt,
|
||||
database: tx,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
if (importData.settings) {
|
||||
await tx.insert(userSettings).values({
|
||||
userId,
|
||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||
notificationEmail: importData.settings.notificationEmail || null,
|
||||
emailStockReminders: importData.settings.emailStockReminders ?? true,
|
||||
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
|
||||
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
|
||||
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
|
||||
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||
highStockDays: importData.settings.highStockDays ?? 180,
|
||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(medications)
|
||||
.values({
|
||||
userId,
|
||||
name: med.name,
|
||||
genericName: med.genericName || null,
|
||||
takenByJson,
|
||||
medicationForm: med.medicationForm ?? "tablet",
|
||||
pillForm: med.pillForm || null,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: normalizePackageType(med.inventory.packageType),
|
||||
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||
packCount: med.inventory.packCount,
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
totalPills: med.inventory.totalPills ?? null,
|
||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
medicationEndDate: med.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||
dismissedUntil: med.dismissedUntil || null,
|
||||
imageUrl: null, // Will be set after image is saved
|
||||
})
|
||||
.returning();
|
||||
for (const share of importData.shareLinks) {
|
||||
await tx.insert(shareTokens).values({
|
||||
userId,
|
||||
token: randomBytes(8).toString("hex"),
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Save mapping
|
||||
exportIdToNewId.set(med._exportId, inserted.id);
|
||||
for (const refill of importData.refillHistory) {
|
||||
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||
if (!newMedId) continue;
|
||||
|
||||
// Save image if present
|
||||
if (med.image) {
|
||||
const imageUrl = base64ToImage(med.image, inserted.id);
|
||||
if (imageUrl) {
|
||||
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
||||
await tx.insert(refillHistory).values({
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: new Date(refill.refillDate),
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
for (const imagePath of newImagePaths) {
|
||||
const removalError = removeFileIfPresent(imagePath);
|
||||
if (removalError) {
|
||||
request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
|
||||
}
|
||||
}
|
||||
|
||||
request.log.error({ err: error }, "[Import] Failed to import data");
|
||||
return reply.status(500).send({ error: "Import failed" });
|
||||
}
|
||||
|
||||
// 4. Import dose history with remapped medication IDs
|
||||
for (const dose of importData.doseHistory) {
|
||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||
if (!newMedId) continue; // Skip orphaned doses
|
||||
|
||||
// Convert ISO timestamp back to milliseconds for dose ID
|
||||
const timestampMs = new Date(dose.scheduledTime).getTime();
|
||||
// Rebuild dose ID with optional person suffix
|
||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Import settings
|
||||
if (importData.settings) {
|
||||
// Legacy exports may still contain shareStockStatus. The current app no longer
|
||||
// uses that setting, so imports accept it for compatibility and then ignore it.
|
||||
await db.insert(userSettings).values({
|
||||
userId,
|
||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||
notificationEmail: importData.settings.notificationEmail || null,
|
||||
emailStockReminders: importData.settings.emailStockReminders ?? true,
|
||||
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
|
||||
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
|
||||
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
|
||||
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||
highStockDays: importData.settings.highStockDays ?? 180,
|
||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Import share links (with new tokens)
|
||||
for (const share of importData.shareLinks) {
|
||||
// Always generate new token for security
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
await db.insert(shareTokens).values({
|
||||
userId,
|
||||
token,
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Import refill history with remapped medication IDs
|
||||
for (const refill of importData.refillHistory) {
|
||||
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||
if (!newMedId) continue; // Skip orphaned refill records
|
||||
|
||||
await db.insert(refillHistory).values({
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: new Date(refill.refillDate),
|
||||
});
|
||||
for (const imagePath of oldImagePaths) {
|
||||
const removalError = removeFileIfPresent(imagePath);
|
||||
if (removalError) {
|
||||
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import {
|
||||
deleteIntakeJournalForDoseEvent,
|
||||
getIntakeJournalForDoseEvent,
|
||||
isTrackedDoseIdFormat,
|
||||
listIntakeJournalEntriesForUser,
|
||||
resolveTrackedDoseEventForUser,
|
||||
upsertIntakeJournalForDoseEvent,
|
||||
} from "../services/intake-journal-service.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const intakeJournalEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
{ bearerAuth: [] },
|
||||
{ cookieAuth: [] },
|
||||
];
|
||||
|
||||
const doseIdParamsSchema = {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const intakeJournalEntrySchema = {
|
||||
type: "object",
|
||||
required: [
|
||||
"doseTrackingId",
|
||||
"doseId",
|
||||
"medicationId",
|
||||
"medicationName",
|
||||
"scheduledFor",
|
||||
"dismissed",
|
||||
"takenSource",
|
||||
"note",
|
||||
"updatedAt",
|
||||
],
|
||||
properties: {
|
||||
doseTrackingId: { type: "integer" },
|
||||
doseId: { type: "string" },
|
||||
medicationId: { type: "integer" },
|
||||
medicationName: { type: "string" },
|
||||
scheduledFor: { type: "string", format: "date-time" },
|
||||
takenAt: { type: ["string", "null"], format: "date-time" },
|
||||
dismissed: { type: "boolean" },
|
||||
takenSource: { type: "string", enum: ["manual", "automatic"] },
|
||||
markedBy: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const intakeJournalEventResponseSchema = {
|
||||
type: "object",
|
||||
required: ["entry"],
|
||||
properties: {
|
||||
entry: intakeJournalEntrySchema,
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const intakeJournalHistoryResponseSchema = {
|
||||
type: "object",
|
||||
required: ["entries"],
|
||||
properties: {
|
||||
entries: {
|
||||
type: "array",
|
||||
items: intakeJournalEntrySchema,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const intakeJournalHistoryQuerySchema = z.object({
|
||||
medicationId: z.coerce.number().int().positive().optional(),
|
||||
from: z.string().trim().min(1).optional(),
|
||||
to: z.string().trim().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||
});
|
||||
|
||||
const intakeJournalUpsertSchema = z.object({
|
||||
note: z.string().max(4000),
|
||||
});
|
||||
|
||||
function getValidationErrorMessage(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) {
|
||||
return "Invalid request payload";
|
||||
}
|
||||
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
function parseOptionalDate(value: string | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dismissed && value.getTime() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function buildJournalEntryDto(input: {
|
||||
event: Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>> extends infer T
|
||||
? T extends null
|
||||
? never
|
||||
: T
|
||||
: never;
|
||||
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
|
||||
}) {
|
||||
const { event, journalEntry } = input;
|
||||
|
||||
return {
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
doseId: event.doseId,
|
||||
medicationId: event.medicationId,
|
||||
medicationName: event.medicationName,
|
||||
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
|
||||
takenAt: serializeTakenAt(event.takenAt, event.dismissed),
|
||||
dismissed: event.dismissed,
|
||||
takenSource: event.takenSource,
|
||||
markedBy: event.markedBy,
|
||||
note: journalEntry?.note ?? null,
|
||||
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
|
||||
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
export async function intakeJournalRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
|
||||
|
||||
app.get<{ Querystring: z.infer<typeof intakeJournalHistoryQuerySchema> }>(
|
||||
"/intake-journal",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "List intake journal history for the current owner",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medicationId: { type: "integer", minimum: 1 },
|
||||
from: { type: "string", format: "date-time" },
|
||||
to: { type: "string", format: "date-time" },
|
||||
limit: { type: "integer", minimum: 1, maximum: 200 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: intakeJournalHistoryResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const from = parseOptionalDate(parsed.data.from);
|
||||
if (parsed.data.from && !from) {
|
||||
return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
|
||||
}
|
||||
|
||||
const to = parseOptionalDate(parsed.data.to);
|
||||
if (parsed.data.to && !to) {
|
||||
return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
|
||||
}
|
||||
|
||||
if (from && to && from.getTime() > to.getTime()) {
|
||||
return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
|
||||
}
|
||||
|
||||
const entries = await listIntakeJournalEntriesForUser({
|
||||
userId,
|
||||
medicationId: parsed.data.medicationId,
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
doseTrackingId: entry.doseTrackingId,
|
||||
doseId: entry.doseId,
|
||||
medicationId: entry.medicationId,
|
||||
medicationName: entry.medicationName,
|
||||
scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
|
||||
takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
|
||||
dismissed: entry.dismissed,
|
||||
takenSource: entry.takenSource,
|
||||
markedBy: entry.markedBy,
|
||||
note: entry.note,
|
||||
updatedAt: entry.updatedAt.toISOString(),
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { doseId: string } }>(
|
||||
"/intake-journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "Get intake journal context for a tracked dose event",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
params: doseIdParamsSchema,
|
||||
response: {
|
||||
200: intakeJournalEventResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { doseId } = request.params;
|
||||
|
||||
if (!isTrackedDoseIdFormat(doseId)) {
|
||||
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
|
||||
return { entry: buildJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Body: z.infer<typeof intakeJournalUpsertSchema>; Params: { doseId: string } }>(
|
||||
"/intake-journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "Create or update an intake journal note for a tracked dose event",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
params: doseIdParamsSchema,
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["note"],
|
||||
properties: {
|
||||
note: { type: "string", maxLength: 4000 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
response: {
|
||||
200: intakeJournalEventResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { doseId } = request.params;
|
||||
|
||||
if (!isTrackedDoseIdFormat(doseId)) {
|
||||
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const parsed = intakeJournalUpsertSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await upsertIntakeJournalForDoseEvent({
|
||||
userId,
|
||||
doseId,
|
||||
note: parsed.data.note,
|
||||
});
|
||||
|
||||
return { entry: buildJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { doseId: string } }>(
|
||||
"/intake-journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "Delete an intake journal note for a tracked dose event",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
params: doseIdParamsSchema,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
required: ["success"],
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { doseId } = request.params;
|
||||
|
||||
if (!isTrackedDoseIdFormat(doseId)) {
|
||||
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
|
||||
if (!deleted) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -45,12 +45,24 @@ type PlannerRow = {
|
||||
|
||||
type SendEmailBody = {
|
||||
email: string;
|
||||
from: string;
|
||||
until: string;
|
||||
from?: string;
|
||||
until?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
rows: PlannerRow[];
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
};
|
||||
|
||||
function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
|
||||
const startDate = body.startDate ?? body.from;
|
||||
const endDate = body.endDate ?? body.until;
|
||||
if (!startDate || !endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
@@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
email: { type: "string" },
|
||||
from: { type: "string" },
|
||||
until: { type: "string" },
|
||||
startDate: { type: "string", format: "date-time" },
|
||||
endDate: { type: "string", format: "date-time" },
|
||||
language: { type: "string" },
|
||||
rows: { type: "array", items: plannerRowSchema },
|
||||
},
|
||||
example: {
|
||||
email: "daniel@example.com",
|
||||
startDate: "2026-03-11T00:00:00.000Z",
|
||||
endDate: "2026-04-11T00:00:00.000Z",
|
||||
from: "2026-03-11",
|
||||
until: "2026-04-11",
|
||||
language: "en",
|
||||
@@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||
const { email, rows, language: bodyLanguage } = request.body;
|
||||
const resolvedDateRange = resolvePlannerDateRange(request.body);
|
||||
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing planner data" });
|
||||
}
|
||||
|
||||
if (!resolvedDateRange) {
|
||||
return reply.status(400).send({ error: "Missing planner date range" });
|
||||
}
|
||||
|
||||
const { startDate, endDate } = resolvedDateRange;
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
@@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
|
||||
const fromDate = escapeHtml(
|
||||
new Date(from).toLocaleDateString(locale, {
|
||||
new Date(startDate).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
);
|
||||
const untilDate = escapeHtml(
|
||||
new Date(until).toLocaleDateString(locale, {
|
||||
new Date(endDate).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, gte, lt } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -12,10 +12,42 @@ import {
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const reportDataSchema = z.object({
|
||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||
});
|
||||
const reportDataSchema = z
|
||||
.object({
|
||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const hasStartDate = typeof value.startDate === "string";
|
||||
const hasEndDate = typeof value.endDate === "string";
|
||||
|
||||
if (hasStartDate !== hasEndDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "startDate and endDate must be provided together",
|
||||
path: hasStartDate ? ["endDate"] : ["startDate"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasStartDate || !hasEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateValue = value.startDate!;
|
||||
const endDateValue = value.endDate!;
|
||||
const startDate = new Date(startDateValue);
|
||||
const endDate = new Date(endDateValue);
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid date range",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const reportDataBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
@@ -27,6 +59,14 @@ const reportDataBodyOpenApiSchema = {
|
||||
maxItems: 100,
|
||||
items: { type: "integer", minimum: 1 },
|
||||
},
|
||||
startDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
endDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
takenByFilter: {
|
||||
type: "array",
|
||||
maxItems: 50,
|
||||
@@ -35,17 +75,47 @@ const reportDataBodyOpenApiSchema = {
|
||||
},
|
||||
example: {
|
||||
medicationIds: [1, 3, 5],
|
||||
startDate: "2026-05-01T00:00:00.000Z",
|
||||
endDate: "2026-06-01T00:00:00.000Z",
|
||||
takenByFilter: ["Daniel"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getPersonTagKey(value: string): string {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
||||
if (!takenByFilter) return true;
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 4) return false;
|
||||
const takenBy = parts.at(-1)?.trim();
|
||||
if (!takenBy) return false;
|
||||
return takenByFilter.has(takenBy);
|
||||
return takenByFilter.has(getPersonTagKey(takenBy));
|
||||
}
|
||||
|
||||
function getDoseScheduledAtMs(doseId: string): number | null {
|
||||
const match = trackedDoseIdPattern.exec(doseId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduledAtMs = Number.parseInt(match[3], 10);
|
||||
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
|
||||
}
|
||||
|
||||
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
|
||||
if (!range) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (timestampMs === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timestampMs >= range.startMs && timestampMs < range.endMs;
|
||||
}
|
||||
|
||||
const reportDataResponseSchema = {
|
||||
@@ -110,10 +180,17 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const { medicationIds, takenByFilter } = parsed.data;
|
||||
const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
|
||||
const normalizedTakenByFilter = takenByFilter?.length
|
||||
? new Set(takenByFilter.map((value) => value.trim()))
|
||||
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
|
||||
: null;
|
||||
const dateRange =
|
||||
startDate && endDate
|
||||
? {
|
||||
startMs: new Date(startDate).getTime(),
|
||||
endMs: new Date(endDate).getTime(),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Verify all medications belong to this user
|
||||
const userMeds = await db
|
||||
@@ -152,6 +229,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
||||
if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({
|
||||
takenAt: dose.takenAt,
|
||||
@@ -191,10 +269,15 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||
|
||||
// Get refills for this medication scoped to the authenticated user.
|
||||
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
|
||||
if (dateRange) {
|
||||
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
|
||||
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
|
||||
}
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
|
||||
.where(and(...refillFilters));
|
||||
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
|
||||
+193
-13
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||
import { redactTokenForLog } from "../utils/redaction.js";
|
||||
import {
|
||||
getAllTakenByForMedication,
|
||||
parseIntakesJson,
|
||||
@@ -28,6 +29,11 @@ import {
|
||||
const createShareSchema = z.object({
|
||||
takenBy: z.string().min(1, "takenBy is required"),
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
expiryDays: z
|
||||
.union([z.number().int().min(1).max(365), z.null()])
|
||||
.optional()
|
||||
.default(null),
|
||||
allowJournalNotes: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
|
||||
|
||||
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
||||
|
||||
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
|
||||
if (expiryDays == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
|
||||
const isoValue = toIsoTimestamp(value);
|
||||
return isoValue != null && new Date(isoValue).getTime() < Date.now();
|
||||
}
|
||||
|
||||
const createShareBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
||||
allowJournalNotes: { type: "boolean", default: false },
|
||||
expiryDays: {
|
||||
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
example: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 14,
|
||||
allowJournalNotes: true,
|
||||
expiryDays: 30,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
|
||||
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
|
||||
upcomingTodayOnly: { type: "boolean" },
|
||||
shareScheduleTodayOnly: { type: "boolean" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareListResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
shareLinks: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
expiresAt: { type: ["string", "null"], format: "date-time" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
shareUrl: { type: "string" },
|
||||
},
|
||||
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["shareLinks"],
|
||||
} as const;
|
||||
|
||||
const ownerTokenParamsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
required: ["token"],
|
||||
} as const;
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
|
||||
request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
`[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
medications: medicationsWithBlisters,
|
||||
shareMedicationOverview,
|
||||
medicationOverview,
|
||||
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
if (!shareTokenPattern.test(token)) {
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
return reply.status(410).send({
|
||||
error: "expired",
|
||||
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
reused: { type: "boolean" },
|
||||
token: { type: "string" },
|
||||
shareUrl: { type: "string" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
expiresAt: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
@@ -390,7 +476,8 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
|
||||
const expiresAt = resolveExpiryDate(expiryDays);
|
||||
|
||||
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
|
||||
const allMeds = await db
|
||||
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||
|
||||
if (existingShare) {
|
||||
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||
const existingTokenRef = redactTokenForLog(existingShare.token);
|
||||
await db
|
||||
.update(shareTokens)
|
||||
.set({ scheduleDays, expiresAt, allowJournalNotes })
|
||||
.where(eq(shareTokens.id, existingShare.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
token: existingShare.token,
|
||||
shareUrl: `/share/${existingShare.token}`,
|
||||
expiresAt: null,
|
||||
allowJournalNotes,
|
||||
expiresAt: toIsoTimestamp(expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomBytes(8).toString("hex");
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
await db.insert(shareTokens).values({
|
||||
userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt: null,
|
||||
allowJournalNotes,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: null,
|
||||
allowJournalNotes,
|
||||
expiresAt: toIsoTimestamp(expiresAt),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share - PROTECTED: List active share links for current owner
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get(
|
||||
"/share",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: shareListResponseSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const shares = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(eq(shareTokens.userId, userId))
|
||||
.orderBy(desc(shareTokens.createdAt));
|
||||
|
||||
return {
|
||||
shareLinks: shares
|
||||
.filter((share) => !isExpiredTimestamp(share.expiresAt))
|
||||
.map((share) => ({
|
||||
token: share.token,
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
|
||||
expiresAt: toIsoTimestamp(share.expiresAt),
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
shareUrl: `/share/${share.token}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token - PROTECTED: Revoke an existing share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string } }>(
|
||||
"/share/:token",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
params: ownerTokenParamsSchema,
|
||||
response: {
|
||||
204: { type: "null" },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const [share] = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
|
||||
|
||||
if (!share) {
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { intakeJournal } from "../db/schema.js";
|
||||
|
||||
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
|
||||
|
||||
export type IntakeJournalExportPayload = {
|
||||
journalNote: string;
|
||||
journalCreatedAt?: string | null;
|
||||
journalUpdatedAt?: string | null;
|
||||
};
|
||||
|
||||
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listIntakeJournalExportPayloadsForUser(
|
||||
userId: number
|
||||
): Promise<Map<number, IntakeJournalExportPayload>> {
|
||||
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.doseTrackingId,
|
||||
{
|
||||
journalNote: row.note,
|
||||
journalCreatedAt: toIsoStringOrNull(row.createdAt),
|
||||
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreIntakeJournalForImportedDose(input: {
|
||||
userId: number;
|
||||
doseTrackingId: number;
|
||||
medicationId: number;
|
||||
scheduledFor: Date;
|
||||
journalNote?: string | null;
|
||||
journalCreatedAt?: string | null;
|
||||
journalUpdatedAt?: string | null;
|
||||
database?: IntakeJournalWriteDatabase;
|
||||
}): Promise<boolean> {
|
||||
const normalizedNote = input.journalNote?.trim() ?? "";
|
||||
if (normalizedNote.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
|
||||
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
|
||||
const database = input.database ?? db;
|
||||
|
||||
await database.insert(intakeJournal).values({
|
||||
userId: input.userId,
|
||||
doseTrackingId: input.doseTrackingId,
|
||||
medicationId: input.medicationId,
|
||||
scheduledFor: input.scheduledFor,
|
||||
note: normalizedNote,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import type { DoseTrackingSource } from "./dose-tracking-service.js";
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
type MedicationTimingRow = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
genericName: string | null;
|
||||
intakesJson: string;
|
||||
usageJson: string;
|
||||
everyJson: string;
|
||||
startJson: string;
|
||||
intakeRemindersEnabled: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedTrackedDoseEvent = {
|
||||
doseTrackingId: number;
|
||||
userId: number;
|
||||
doseId: string;
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date;
|
||||
markedBy: string | null;
|
||||
takenSource: DoseTrackingSource;
|
||||
dismissed: boolean;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
|
||||
|
||||
export type IntakeJournalHistoryEntry = {
|
||||
id: number;
|
||||
doseTrackingId: number;
|
||||
doseId: string;
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date;
|
||||
markedBy: string | null;
|
||||
takenSource: DoseTrackingSource;
|
||||
dismissed: boolean;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||
const match = doseIdPattern.exec(doseId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const timestampMs = Number.parseInt(match[3], 10);
|
||||
const personSuffix = match[4] ? match[4].trim() : null;
|
||||
|
||||
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
medicationId,
|
||||
intakeIndex,
|
||||
timestampMs,
|
||||
personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
export function isTrackedDoseIdFormat(doseId: string): boolean {
|
||||
return parseDoseId(doseId) !== null;
|
||||
}
|
||||
|
||||
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
|
||||
const commercialName = medication.name?.trim() ?? "";
|
||||
if (commercialName.length > 0) {
|
||||
return commercialName;
|
||||
}
|
||||
|
||||
const genericName = medication.genericName?.trim() ?? "";
|
||||
if (genericName.length > 0) {
|
||||
return genericName;
|
||||
}
|
||||
|
||||
return `Medication #${medication.id}`;
|
||||
}
|
||||
|
||||
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{
|
||||
usageJson: medication.usageJson,
|
||||
everyJson: medication.everyJson,
|
||||
startJson: medication.startJson,
|
||||
},
|
||||
medication.intakeRemindersEnabled
|
||||
);
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
if (!intake) {
|
||||
return new Date(parsedDose.timestampMs);
|
||||
}
|
||||
|
||||
const doseDate = new Date(parsedDose.timestampMs);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
|
||||
return new Date(
|
||||
doseDate.getFullYear(),
|
||||
doseDate.getMonth(),
|
||||
doseDate.getDate(),
|
||||
intakeStart.getHours(),
|
||||
intakeStart.getMinutes(),
|
||||
intakeStart.getSeconds(),
|
||||
intakeStart.getMilliseconds()
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveTrackedDoseEventForUser(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
}): Promise<ResolvedTrackedDoseEvent | null> {
|
||||
const parsedDose = parseDoseId(input.doseId);
|
||||
if (!parsedDose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [event] = await db
|
||||
.select({
|
||||
doseTrackingId: doseTracking.id,
|
||||
userId: doseTracking.userId,
|
||||
doseId: doseTracking.doseId,
|
||||
takenAt: doseTracking.takenAt,
|
||||
markedBy: doseTracking.markedBy,
|
||||
takenSource: doseTracking.takenSource,
|
||||
dismissed: doseTracking.dismissed,
|
||||
medicationId: medications.id,
|
||||
medicationName: medications.name,
|
||||
medicationGenericName: medications.genericName,
|
||||
intakesJson: medications.intakesJson,
|
||||
usageJson: medications.usageJson,
|
||||
everyJson: medications.everyJson,
|
||||
startJson: medications.startJson,
|
||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
|
||||
.limit(1);
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduledFor = resolveScheduledFor(parsedDose, {
|
||||
id: event.medicationId,
|
||||
name: event.medicationName,
|
||||
genericName: event.medicationGenericName,
|
||||
intakesJson: event.intakesJson,
|
||||
usageJson: event.usageJson,
|
||||
everyJson: event.everyJson,
|
||||
startJson: event.startJson,
|
||||
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
|
||||
});
|
||||
|
||||
return {
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
userId: event.userId,
|
||||
doseId: event.doseId,
|
||||
medicationId: event.medicationId,
|
||||
medicationName: getMedicationDisplayName({
|
||||
id: event.medicationId,
|
||||
name: event.medicationName,
|
||||
genericName: event.medicationGenericName,
|
||||
}),
|
||||
scheduledFor,
|
||||
takenAt: event.takenAt,
|
||||
markedBy: event.markedBy,
|
||||
takenSource: event.takenSource as DoseTrackingSource,
|
||||
dismissed: event.dismissed ?? false,
|
||||
personSuffix: parsedDose.personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIntakeJournalForDoseEvent(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
}): Promise<IntakeJournalEntry | null> {
|
||||
const event = await resolveTrackedDoseEventForUser(input);
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [journalEntry] = await db
|
||||
.select()
|
||||
.from(intakeJournal)
|
||||
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
|
||||
.limit(1);
|
||||
|
||||
return journalEntry ?? null;
|
||||
}
|
||||
|
||||
export async function upsertIntakeJournalForDoseEvent(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
note: string;
|
||||
}): Promise<IntakeJournalEntry | null> {
|
||||
const normalizedNote = input.note.trim();
|
||||
if (normalizedNote.length === 0) {
|
||||
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(intakeJournal)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
medicationId: event.medicationId,
|
||||
scheduledFor: event.scheduledFor,
|
||||
note: normalizedNote,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: intakeJournal.doseTrackingId,
|
||||
set: {
|
||||
userId: input.userId,
|
||||
medicationId: event.medicationId,
|
||||
note: normalizedNote,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
|
||||
}
|
||||
|
||||
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
|
||||
const event = await resolveTrackedDoseEventForUser(input);
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(intakeJournal)
|
||||
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listIntakeJournalEntriesForUser(input: {
|
||||
userId: number;
|
||||
medicationId?: number;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
limit?: number;
|
||||
}): Promise<IntakeJournalHistoryEntry[]> {
|
||||
const filters = [eq(intakeJournal.userId, input.userId)];
|
||||
|
||||
if (typeof input.medicationId === "number") {
|
||||
filters.push(eq(intakeJournal.medicationId, input.medicationId));
|
||||
}
|
||||
|
||||
if (input.from) {
|
||||
filters.push(gte(intakeJournal.scheduledFor, input.from));
|
||||
}
|
||||
|
||||
if (input.to) {
|
||||
filters.push(lte(intakeJournal.scheduledFor, input.to));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: intakeJournal.id,
|
||||
doseTrackingId: intakeJournal.doseTrackingId,
|
||||
doseId: doseTracking.doseId,
|
||||
medicationId: intakeJournal.medicationId,
|
||||
medicationName: medications.name,
|
||||
medicationGenericName: medications.genericName,
|
||||
scheduledFor: intakeJournal.scheduledFor,
|
||||
takenAt: doseTracking.takenAt,
|
||||
markedBy: doseTracking.markedBy,
|
||||
takenSource: doseTracking.takenSource,
|
||||
dismissed: doseTracking.dismissed,
|
||||
note: intakeJournal.note,
|
||||
createdAt: intakeJournal.createdAt,
|
||||
updatedAt: intakeJournal.updatedAt,
|
||||
})
|
||||
.from(intakeJournal)
|
||||
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
|
||||
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
|
||||
.limit(input.limit ?? 100);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
doseTrackingId: row.doseTrackingId,
|
||||
doseId: row.doseId,
|
||||
medicationId: row.medicationId,
|
||||
medicationName: getMedicationDisplayName({
|
||||
id: row.medicationId,
|
||||
name: row.medicationName,
|
||||
genericName: row.medicationGenericName,
|
||||
}),
|
||||
scheduledFor: row.scheduledFor,
|
||||
takenAt: row.takenAt,
|
||||
markedBy: row.markedBy,
|
||||
takenSource: row.takenSource as DoseTrackingSource,
|
||||
dismissed: row.dismissed ?? false,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
@@ -51,6 +51,7 @@ const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM intake_journal");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM api_keys");
|
||||
@@ -78,20 +79,30 @@ async function insertMedication(options: {
|
||||
start?: string;
|
||||
}) {
|
||||
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
|
||||
const takenBy = options.takenBy ?? [];
|
||||
const intakeTakenBy = takenBy[0] ?? null;
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
|
||||
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
JSON.stringify(options.takenBy ?? []),
|
||||
JSON.stringify(takenBy),
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
intakeStart,
|
||||
"[]",
|
||||
JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: intakeStart,
|
||||
takenBy: intakeTakenBy,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat
|
||||
});
|
||||
}
|
||||
|
||||
async function _insertShareToken(userId: number, token: string, takenBy: string) {
|
||||
async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
|
||||
args: [userId, token, takenBy],
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)",
|
||||
args: [userId, token, takenBy, allowJournalNotes ? 1 : 0],
|
||||
});
|
||||
}
|
||||
|
||||
function buildLocalDoseStart(hours = 8): string {
|
||||
const start = new Date();
|
||||
start.setHours(hours, 0, 0, 0);
|
||||
const year = start.getFullYear();
|
||||
const month = String(start.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(start.getDate()).padStart(2, "0");
|
||||
const hour = String(start.getHours()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:00:00.000`;
|
||||
}
|
||||
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
@@ -458,6 +480,48 @@ describe("Dose Tracking API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("single-dose skip routes", () => {
|
||||
it("marks a single owner dose as skipped through the frontend route", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/skip",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
|
||||
});
|
||||
|
||||
it("undoes a skipped-only owner dose through the frontend route", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId, dismissed: true, takenAt: null });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/skip/${encodeURIComponent(doseId)}`,
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(Number(result.rows[0].count)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /doses/dismiss", () => {
|
||||
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
|
||||
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
|
||||
@@ -481,4 +545,174 @@ describe("Dose Tracking API", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared single-dose skip routes", () => {
|
||||
it("marks and undoes a visible shared dose as skipped", async () => {
|
||||
const start = buildLocalDoseStart();
|
||||
await insertMedication({
|
||||
id: 6,
|
||||
userId,
|
||||
takenBy: ["Max"],
|
||||
start,
|
||||
});
|
||||
await _insertShareToken(userId, "share-skip-token", "Max", false);
|
||||
|
||||
const doseId = `6-0-${new Date(start).getTime()}-Max`;
|
||||
|
||||
const skipResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share/share-skip-token/doses/skip",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(skipResponse.statusCode).toBe(200);
|
||||
expect(skipResponse.json()).toEqual({ success: true });
|
||||
|
||||
const skippedRows = await testClient.execute({
|
||||
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
|
||||
|
||||
const undoResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(undoResponse.statusCode).toBe(200);
|
||||
expect(undoResponse.json()).toEqual({ success: true });
|
||||
|
||||
const remainingRows = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(Number(remainingRows.rows[0].count)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shared journal notes", () => {
|
||||
it("rejects shared journal access when the share link does not allow notes", async () => {
|
||||
const start = buildLocalDoseStart();
|
||||
await insertMedication({
|
||||
id: 7,
|
||||
userId,
|
||||
takenBy: ["Max"],
|
||||
start,
|
||||
});
|
||||
await _insertShareToken(userId, "token-no-notes", "Max", false);
|
||||
|
||||
const doseId = `7-0-${new Date(start).getTime()}-Max`;
|
||||
await insertDose({ userId, doseId, markedBy: "Max" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({
|
||||
error: "Journal notes are not enabled for this share link",
|
||||
code: "NOT_ENABLED",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports shared journal note read and save, but not implicit or explicit delete", async () => {
|
||||
const start = buildLocalDoseStart();
|
||||
await insertMedication({
|
||||
id: 8,
|
||||
userId,
|
||||
takenBy: ["Max"],
|
||||
start,
|
||||
});
|
||||
await _insertShareToken(userId, "token-with-notes", "Max", true);
|
||||
|
||||
const doseId = `8-0-${new Date(start).getTime()}-Max`;
|
||||
await insertDose({ userId, doseId, markedBy: "Max" });
|
||||
|
||||
const initialResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(initialResponse.statusCode).toBe(200);
|
||||
expect(initialResponse.json().entry).toEqual(
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
markedBy: "Max",
|
||||
note: null,
|
||||
})
|
||||
);
|
||||
|
||||
const initialDosesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share/token-with-notes/doses",
|
||||
});
|
||||
|
||||
expect(initialDosesResponse.statusCode).toBe(200);
|
||||
expect(initialDosesResponse.json().doses).toEqual([
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
hasJournalNote: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const saveResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
payload: { note: "Shared note from Max" },
|
||||
});
|
||||
|
||||
expect(saveResponse.statusCode).toBe(200);
|
||||
expect(saveResponse.json().entry).toEqual(
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
note: "Shared note from Max",
|
||||
})
|
||||
);
|
||||
|
||||
const savedDosesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share/token-with-notes/doses",
|
||||
});
|
||||
|
||||
expect(savedDosesResponse.statusCode).toBe(200);
|
||||
expect(savedDosesResponse.json().doses).toEqual([
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
hasJournalNote: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const blankSaveResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
payload: { note: " " },
|
||||
});
|
||||
|
||||
expect(blankSaveResponse.statusCode).toBe(400);
|
||||
expect(blankSaveResponse.json()).toEqual({
|
||||
error: "Journal note cannot be empty",
|
||||
code: "EMPTY_NOTE",
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(403);
|
||||
expect(deleteResponse.json()).toEqual({
|
||||
error: "Shared links cannot delete journal notes",
|
||||
code: "DELETE_NOT_ALLOWED",
|
||||
});
|
||||
|
||||
const journalRows = await testClient.execute({
|
||||
sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?",
|
||||
args: [userId, 8],
|
||||
});
|
||||
|
||||
expect(journalRows.rows).toHaveLength(1);
|
||||
expect(journalRows.rows[0].note).toBe("Shared note from Max");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* These tests import the actual route handlers for real coverage.
|
||||
*/
|
||||
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import cookie from "@fastify/cookie";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import sensible from "@fastify/sensible";
|
||||
@@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
const { testClient, testDb, testDbPath } = vi.hoisted(() => {
|
||||
// Dynamic import inside hoisted block
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join } = require("node:path");
|
||||
const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`);
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
const db = drizzle(client);
|
||||
return { testClient: client, testDb: db };
|
||||
return { testClient: client, testDb: db, testDbPath: dbPath };
|
||||
});
|
||||
|
||||
// Mock modules using the hoisted db
|
||||
@@ -171,6 +175,7 @@ async function createSchema(client: Client) {
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -184,6 +189,19 @@ async function createSchema(client: Client) {
|
||||
taken_source text NOT NULL DEFAULT 'manual',
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS intake_journal (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_tracking_id integer NOT NULL UNIQUE,
|
||||
medication_id integer NOT NULL,
|
||||
scheduled_for integer NOT NULL,
|
||||
note text NOT NULL,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -204,6 +222,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM intake_journal");
|
||||
await client.execute("DELETE FROM refill_history");
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
@@ -222,10 +241,11 @@ async function _createUser(client: Client, username: string): Promise<number> {
|
||||
}
|
||||
|
||||
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
|
||||
const start = new Date(visibleDoseTimestampMs()).toISOString();
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
|
||||
args: [userId, name, JSON.stringify(takenBy)],
|
||||
VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`,
|
||||
args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])],
|
||||
});
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
@@ -237,6 +257,12 @@ async function createShareToken(client: Client, userId: number, takenBy: string,
|
||||
});
|
||||
}
|
||||
|
||||
function visibleDoseTimestampMs(): number {
|
||||
const doseDate = new Date();
|
||||
doseDate.setHours(8, 0, 0, 0);
|
||||
return doseDate.getTime();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// E2E Tests with Real Routes
|
||||
// =============================================================================
|
||||
@@ -386,6 +412,11 @@ describe("E2E Tests with Real Routes", () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -508,12 +539,12 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should mark dose via share link using real route", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
|
||||
const token = "test_share_token_456";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
const doseId = "1-0-1735344000000";
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
@@ -1039,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should unmark dose via share link", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
|
||||
const token = "test_delete_dose_token";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
// First mark the dose
|
||||
const doseId = "1-0-1735344000000";
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, doseId, "Daniel"],
|
||||
@@ -1089,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should return already marked message for duplicate dose", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
|
||||
const token = "test_duplicate_token";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
const doseId = "1-0-1735344000000";
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
|
||||
// Mark the dose first time
|
||||
await app.inject({
|
||||
@@ -1530,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Share token management", () => {
|
||||
it("should list active share links for the owner", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 90,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share",
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
const data = listResponse.json();
|
||||
expect(data.shareLinks).toHaveLength(1);
|
||||
expect(data.shareLinks[0].takenBy).toBe("Daniel");
|
||||
});
|
||||
|
||||
it("should revoke an active share link", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const { token } = createResponse.json();
|
||||
const revokeResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(revokeResponse.statusCode).toBe(204);
|
||||
|
||||
const publicResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(publicResponse.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should create share token with custom scheduleDays", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
@@ -1548,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a share token with an expiry and keep it in the active owner list", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 30,
|
||||
expiryDays: 7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const created = createResponse.json();
|
||||
expect(created.expiresAt).toBeTruthy();
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share",
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
const listData = listResponse.json();
|
||||
expect(listData.shareLinks).toHaveLength(1);
|
||||
expect(listData.shareLinks[0].expiresAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return validation error for invalid scheduleDays", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
@@ -1685,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
describe("Share token dose routes", () => {
|
||||
it("should get taken doses via share link", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const token = "get-doses-token";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
// Insert a dose directly
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, "1-0-1735344000000", "Daniel"],
|
||||
args: [userId, doseId, "Daniel"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -1703,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(1);
|
||||
expect(data.doses[0].doseId).toBe("1-0-1735344000000");
|
||||
expect(data.doses[0].doseId).toBe(doseId);
|
||||
expect(data.doses[0].markedBy).toBe("Daniel");
|
||||
});
|
||||
|
||||
@@ -3000,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
describe("Real /import routes", () => {
|
||||
it("should preview import data without mutating existing user data", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Existing Med",
|
||||
packCount: 2,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
const previewPayload = {
|
||||
version: "1.6",
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: true,
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Imported Med",
|
||||
inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
|
||||
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
],
|
||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||
shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }],
|
||||
doseHistory: [
|
||||
{
|
||||
medicationRef: "med-1",
|
||||
scheduleIndex: 0,
|
||||
scheduledTime: "2025-01-01T08:00:00.000Z",
|
||||
takenAt: "2025-01-01T08:03:00.000Z",
|
||||
journalNote: "after breakfast",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const previewResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import/preview",
|
||||
payload: previewPayload,
|
||||
});
|
||||
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(previewResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: {
|
||||
version: "1.6",
|
||||
includeSensitiveData: true,
|
||||
incoming: {
|
||||
medications: 1,
|
||||
doseHistory: 1,
|
||||
shareLinks: 1,
|
||||
journalEntries: 1,
|
||||
hasSettings: true,
|
||||
},
|
||||
current: {
|
||||
medications: 1,
|
||||
hasSettings: false,
|
||||
},
|
||||
warnings: {
|
||||
replacesExistingData: true,
|
||||
regeneratesShareLinks: true,
|
||||
containsSensitiveData: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||
expect(medsResponse.json()).toHaveLength(1);
|
||||
expect(medsResponse.json()[0].name).toBe("Existing Med");
|
||||
});
|
||||
|
||||
it("should import medications from export format", async () => {
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join } = require("node:path");
|
||||
const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`);
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
const db = drizzle(client);
|
||||
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
testDbPath: dbPath,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: true,
|
||||
REGISTRATION_ENABLED: true,
|
||||
FORM_LOGIN_ENABLED: true,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
JWT_SECRET: "test-jwt-secret",
|
||||
REFRESH_SECRET: "test-refresh-secret",
|
||||
COOKIE_SECRET: "test-cookie-secret",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
OPENAPI_DOCS_ENABLED: false,
|
||||
PUBLIC_APP_URL: "https://app.example.com",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
const { intakeJournalRoutes } = await import("../routes/intake-journal.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM intake_journal");
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM api_keys");
|
||||
await testClient.execute("DELETE FROM refresh_tokens");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function createUser(username: string) {
|
||||
const result = await testClient.execute({
|
||||
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||
args: [username],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
}
|
||||
|
||||
async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) {
|
||||
const start = options.start ?? "2026-02-01T08:00:00.000Z";
|
||||
const takenBy = options.takenBy ?? ["Daniel"];
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
stock_adjustment, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
options.userId,
|
||||
options.name,
|
||||
`${options.name} Generic`,
|
||||
JSON.stringify(takenBy),
|
||||
"tablet",
|
||||
"blister",
|
||||
1,
|
||||
1,
|
||||
10,
|
||||
0,
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([start]),
|
||||
JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start,
|
||||
takenBy: takenBy[0] ?? null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
]),
|
||||
0,
|
||||
1,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function seedTrackedDose(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
takenAt: Date;
|
||||
markedBy?: string | null;
|
||||
dismissed?: boolean;
|
||||
}) {
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
options.userId,
|
||||
options.doseId,
|
||||
Math.floor(options.takenAt.getTime() / 1000),
|
||||
options.markedBy ?? null,
|
||||
"manual",
|
||||
options.dismissed ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
describe("Intake journal routes", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await clearTables();
|
||||
});
|
||||
|
||||
it("keeps journal CRUD/history owner-scoped across route access", async () => {
|
||||
const ownerId = await createUser("journal-owner");
|
||||
const otherId = await createUser("journal-other");
|
||||
const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner");
|
||||
const otherCookie = await buildSessionCookie(app, otherId, "journal-other");
|
||||
|
||||
const ownerStart = "2026-02-01T08:00:00.000Z";
|
||||
const otherStart = "2026-02-02T09:00:00.000Z";
|
||||
const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart });
|
||||
const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart });
|
||||
|
||||
const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`;
|
||||
const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`;
|
||||
await seedTrackedDose({
|
||||
userId: ownerId,
|
||||
doseId: ownerDoseId,
|
||||
takenAt: new Date("2026-02-01T08:05:00.000Z"),
|
||||
markedBy: "Daniel",
|
||||
});
|
||||
await seedTrackedDose({
|
||||
userId: otherId,
|
||||
doseId: otherDoseId,
|
||||
takenAt: new Date("2026-02-02T09:05:00.000Z"),
|
||||
markedBy: "Maria",
|
||||
});
|
||||
|
||||
const ownerPutResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
|
||||
headers: { cookie: ownerCookie },
|
||||
payload: { note: "Took after breakfast." },
|
||||
});
|
||||
|
||||
expect(ownerPutResponse.statusCode).toBe(200);
|
||||
expect(ownerPutResponse.json().entry).toEqual(
|
||||
expect.objectContaining({
|
||||
doseId: ownerDoseId,
|
||||
medicationId: ownerMedicationId,
|
||||
scheduledFor: expect.stringContaining("T08:00:00"),
|
||||
note: "Took after breakfast.",
|
||||
})
|
||||
);
|
||||
|
||||
const otherPutResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
|
||||
headers: { cookie: otherCookie },
|
||||
payload: { note: "Different owner note." },
|
||||
});
|
||||
|
||||
expect(otherPutResponse.statusCode).toBe(200);
|
||||
|
||||
const ownerHistoryResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`,
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(ownerHistoryResponse.statusCode).toBe(200);
|
||||
expect(ownerHistoryResponse.json().entries).toEqual([
|
||||
expect.objectContaining({
|
||||
doseId: ownerDoseId,
|
||||
medicationId: ownerMedicationId,
|
||||
note: "Took after breakfast.",
|
||||
markedBy: "Daniel",
|
||||
}),
|
||||
]);
|
||||
|
||||
const otherEventResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(otherEventResponse.statusCode).toBe(404);
|
||||
expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" });
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(deleteResponse.json()).toEqual({ success: true });
|
||||
|
||||
const emptyHistoryResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/intake-journal",
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(emptyHistoryResponse.statusCode).toBe(200);
|
||||
expect(emptyHistoryResponse.json().entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves journal metadata through authenticated export and import", async () => {
|
||||
const userId = await createUser("journal-roundtrip");
|
||||
const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip");
|
||||
const start = "2026-02-03T07:30:00.000Z";
|
||||
const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start });
|
||||
const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`;
|
||||
const doseTrackingId = await seedTrackedDose({
|
||||
userId,
|
||||
doseId,
|
||||
takenAt: new Date("2026-02-03T07:33:00.000Z"),
|
||||
markedBy: "Daniel",
|
||||
});
|
||||
|
||||
const createdAt = new Date("2026-02-03T07:40:00.000Z");
|
||||
const updatedAt = new Date("2026-02-03T07:50:00.000Z");
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO intake_journal (
|
||||
user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
doseTrackingId,
|
||||
medicationId,
|
||||
Math.floor(new Date(start).getTime() / 1000),
|
||||
"Roundtrip journal note",
|
||||
Math.floor(createdAt.getTime() / 1000),
|
||||
Math.floor(updatedAt.getTime() / 1000),
|
||||
],
|
||||
});
|
||||
|
||||
const exportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
headers: { cookie: sessionCookie },
|
||||
});
|
||||
|
||||
expect(exportResponse.statusCode).toBe(200);
|
||||
const exportBody = exportResponse.json();
|
||||
expect(exportBody.doseHistory).toHaveLength(1);
|
||||
expect(exportBody.doseHistory[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
journalNote: "Roundtrip journal note",
|
||||
journalCreatedAt: createdAt.toISOString(),
|
||||
journalUpdatedAt: updatedAt.toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
headers: { cookie: sessionCookie },
|
||||
payload: exportBody,
|
||||
});
|
||||
|
||||
expect(importResponse.statusCode).toBe(200);
|
||||
|
||||
const reExportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
headers: { cookie: sessionCookie },
|
||||
});
|
||||
|
||||
expect(reExportResponse.statusCode).toBe(200);
|
||||
expect(reExportResponse.json().doseHistory).toEqual([
|
||||
expect.objectContaining({
|
||||
journalNote: "Roundtrip journal note",
|
||||
journalCreatedAt: createdAt.toISOString(),
|
||||
journalUpdatedAt: updatedAt.toISOString(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const restoredJournalRows = await testClient.execute({
|
||||
sql: "SELECT note FROM intake_journal WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
expect(restoredJournalRows.rows).toHaveLength(1);
|
||||
expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note");
|
||||
});
|
||||
|
||||
it("preserves the shared journal-note permission through authenticated export and import", async () => {
|
||||
const userId = await createUser("share-journal-roundtrip");
|
||||
const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip");
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [userId, "share-journal-token", "Daniel", 14, 1, null],
|
||||
});
|
||||
|
||||
const exportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
headers: { cookie: sessionCookie },
|
||||
});
|
||||
|
||||
expect(exportResponse.statusCode).toBe(200);
|
||||
const exportBody = exportResponse.json();
|
||||
expect(exportBody.shareLinks).toEqual([
|
||||
expect.objectContaining({
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 14,
|
||||
allowJournalNotes: true,
|
||||
regenerateToken: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
headers: { cookie: sessionCookie },
|
||||
payload: exportBody,
|
||||
});
|
||||
|
||||
expect(importResponse.statusCode).toBe(200);
|
||||
|
||||
const shareRows = await testClient.execute({
|
||||
sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
expect(shareRows.rows).toHaveLength(1);
|
||||
expect(shareRows.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
taken_by: "Daniel",
|
||||
schedule_days: 14,
|
||||
allow_journal_notes: 1,
|
||||
})
|
||||
);
|
||||
expect(shareRows.rows[0].token).not.toBe("share-journal-token");
|
||||
});
|
||||
|
||||
it("keeps existing data when import fails inside the replacement transaction", async () => {
|
||||
const userId = await createUser("import-rollback");
|
||||
const sessionCookie = await buildSessionCookie(app, userId, "import-rollback");
|
||||
await seedMedication({ userId, name: "Existing Rollback Med" });
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
headers: { cookie: sessionCookie },
|
||||
payload: {
|
||||
version: "1.6",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Imported Rollback Med",
|
||||
inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 },
|
||||
schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }],
|
||||
},
|
||||
],
|
||||
doseHistory: [
|
||||
{
|
||||
medicationRef: "med-1",
|
||||
scheduleIndex: 0,
|
||||
scheduledTime: "2026-02-04T08:00:00.000Z",
|
||||
takenAt: "not-a-date",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(importResponse.statusCode).toBe(500);
|
||||
|
||||
const medicationRows = await testClient.execute({
|
||||
sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name",
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -195,6 +196,16 @@ async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
}
|
||||
|
||||
function visibleDoseTimestampMs(): number {
|
||||
const doseDate = new Date();
|
||||
doseDate.setHours(8, 0, 0, 0);
|
||||
return doseDate.getTime();
|
||||
}
|
||||
|
||||
function visibleDoseStartIso(): string {
|
||||
return new Date(visibleDoseTimestampMs()).toISOString();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
@@ -259,9 +270,11 @@ describe("Integration Tests", () => {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
looseTablets: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
|
||||
@@ -617,9 +630,10 @@ describe("Integration Tests", () => {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Create share token for Daniel
|
||||
@@ -628,15 +642,17 @@ describe("Integration Tests", () => {
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||
});
|
||||
expect(shareRes.statusCode, shareRes.body).toBe(200);
|
||||
const token = shareRes.json().token;
|
||||
|
||||
// Mark dose via share link
|
||||
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
|
||||
await app.inject({
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
const markRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
expect(markRes.statusCode, markRes.body).toBe(200);
|
||||
|
||||
// Verify markedBy is "Daniel"
|
||||
const result = await testClient.execute({
|
||||
@@ -667,9 +683,10 @@ describe("Integration Tests", () => {
|
||||
payload: {
|
||||
name: "Vitamin D",
|
||||
takenBy: ["Anna"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Create share token
|
||||
@@ -678,21 +695,24 @@ describe("Integration Tests", () => {
|
||||
url: "/share",
|
||||
payload: { takenBy: "Anna", scheduleDays: 30 },
|
||||
});
|
||||
expect(shareRes.statusCode, shareRes.body).toBe(200);
|
||||
const token = shareRes.json().token;
|
||||
|
||||
// Mark a dose
|
||||
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
|
||||
await app.inject({
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
const markRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
expect(markRes.statusCode, markRes.body).toBe(200);
|
||||
|
||||
// Get shared schedule
|
||||
const scheduleRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
expect(scheduleRes.statusCode, scheduleRes.body).toBe(200);
|
||||
|
||||
const data = scheduleRes.json();
|
||||
expect(data.takenBy).toBe("Anna");
|
||||
@@ -781,7 +801,7 @@ describe("Integration Tests", () => {
|
||||
payload: {
|
||||
name: "Family Vitamins",
|
||||
takenBy: ["Daniel", "Anna", "Max"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -799,8 +819,8 @@ describe("Integration Tests", () => {
|
||||
});
|
||||
|
||||
// Both should succeed with different tokens
|
||||
expect(danielShare.statusCode).toBe(200);
|
||||
expect(annaShare.statusCode).toBe(200);
|
||||
expect(danielShare.statusCode, danielShare.body).toBe(200);
|
||||
expect(annaShare.statusCode, annaShare.body).toBe(200);
|
||||
expect(danielShare.json().token).not.toBe(annaShare.json().token);
|
||||
|
||||
// Each share link should show correct person
|
||||
|
||||
@@ -248,6 +248,32 @@ describe("Planner Routes", () => {
|
||||
expect(response.json()).toEqual({ error: "Missing planner data" });
|
||||
});
|
||||
|
||||
it("should reject request when no planner date range can be resolved", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing planner date range" });
|
||||
});
|
||||
|
||||
it("should return error when no notification channels configured", async () => {
|
||||
// User settings exist but email/shoutrrr disabled
|
||||
await testClient.execute({
|
||||
@@ -282,6 +308,51 @@ describe("Planner Routes", () => {
|
||||
expect(response.json()).toEqual({ error: "No notification channels configured" });
|
||||
});
|
||||
|
||||
it("should accept startDate and endDate aliases for planner range", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-31T00:00:00.000Z",
|
||||
language: "en",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should send email successfully when SMTP is configured", async () => {
|
||||
// Set SMTP env vars
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactTokenForLog } from "../utils/redaction.js";
|
||||
|
||||
describe("redactTokenForLog", () => {
|
||||
it("returns a stable short hash reference without exposing the raw token", () => {
|
||||
const rawToken = "share-token-secret-value";
|
||||
const tokenRef = redactTokenForLog(rawToken);
|
||||
|
||||
expect(tokenRef).toMatch(/^sha256:[a-f0-9]{12}$/);
|
||||
expect(tokenRef).toBe(redactTokenForLog(rawToken));
|
||||
expect(tokenRef).not.toContain(rawToken);
|
||||
});
|
||||
|
||||
it("normalizes empty tokens to a non-sensitive placeholder", () => {
|
||||
expect(redactTokenForLog("")).toBe("missing");
|
||||
expect(redactTokenForLog(" ")).toBe("missing");
|
||||
expect(redactTokenForLog(null)).toBe("missing");
|
||||
expect(redactTokenForLog(undefined)).toBe("missing");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
@@ -6,10 +7,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||
const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join } = require("node:path");
|
||||
const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`);
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
const db = drizzle(client);
|
||||
const env = {
|
||||
AUTH_ENABLED: false,
|
||||
@@ -22,6 +26,7 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
testDbPath: dbPath,
|
||||
mockedEnv: env,
|
||||
nodemailerSendMail: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
@@ -121,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
if (existsSync(testDbPath)) {
|
||||
unlinkSync(testDbPath);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -647,7 +655,7 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
|
||||
args: [1, `${medId}-0-1700000600000-alice`, 1700000600, 1],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
@@ -665,6 +673,66 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(body[medId].dosesSkipped).toBe(1);
|
||||
});
|
||||
|
||||
it("POST /medications/report-data filters doses by scheduled doseId timestamp and refills by the same date window", async () => {
|
||||
const medId = await seedMedication("Report Date Range Med");
|
||||
const windowStart = "2026-01-10T00:00:00.000Z";
|
||||
const windowEnd = "2026-01-20T00:00:00.000Z";
|
||||
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
args: [
|
||||
1,
|
||||
`${medId}-0-${Date.parse("2026-01-05T09:00:00.000Z")}-Daniel`,
|
||||
Math.floor(Date.parse("2026-01-12T09:00:00.000Z") / 1000),
|
||||
0,
|
||||
],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
args: [
|
||||
1,
|
||||
`${medId}-0-${Date.parse("2026-01-15T09:00:00.000Z")}-Daniel`,
|
||||
Math.floor(Date.parse("2026-01-25T09:00:00.000Z") / 1000),
|
||||
0,
|
||||
],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
args: [
|
||||
1,
|
||||
`${medId}-0-${Date.parse("2026-01-18T09:00:00.000Z")}-Daniel`,
|
||||
Math.floor(Date.parse("2026-01-18T09:30:00.000Z") / 1000),
|
||||
1,
|
||||
],
|
||||
});
|
||||
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
args: [medId, 1, 1, 0, 0, Math.floor(Date.parse("2026-01-12T08:00:00.000Z") / 1000)],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
args: [medId, 1, 9, 0, 1, Math.floor(Date.parse("2026-01-22T08:00:00.000Z") / 1000)],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [medId], startDate: windowStart, endDate: windowEnd },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body[medId]).toMatchObject({
|
||||
dosesTaken: 1,
|
||||
dosesSkipped: 1,
|
||||
});
|
||||
expect(body[medId].refills).toHaveLength(1);
|
||||
expect(body[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 1,
|
||||
usedPrescription: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||
const medId = await seedMedication("Export Med");
|
||||
await testClient.execute({
|
||||
|
||||
@@ -177,18 +177,26 @@ export interface CreateShareTokenOptions {
|
||||
token?: string;
|
||||
scheduleDays?: number;
|
||||
expiresAt?: number | null;
|
||||
allowJournalNotes?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test share token and return the token string
|
||||
*/
|
||||
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
|
||||
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
|
||||
const {
|
||||
userId,
|
||||
takenBy,
|
||||
token = `test_token_${Date.now()}`,
|
||||
scheduleDays = 30,
|
||||
expiresAt = null,
|
||||
allowJournalNotes = false,
|
||||
} = options;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0],
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function pad(value: number, size = 2): string {
|
||||
return String(value).padStart(size, "0");
|
||||
}
|
||||
|
||||
export function toLocalDateTimeOffsetString(value: Date): string {
|
||||
const offsetMinutes = -value.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
|
||||
const offsetHours = Math.floor(absoluteOffsetMinutes / 60);
|
||||
const offsetRemainderMinutes = absoluteOffsetMinutes % 60;
|
||||
|
||||
return [
|
||||
`${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`,
|
||||
`T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`,
|
||||
`${sign}${pad(offsetHours)}:${pad(offsetRemainderMinutes)}`,
|
||||
].join("");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function redactTokenForLog(token: string | null | undefined): string {
|
||||
const normalizedToken = token?.trim();
|
||||
if (!normalizedToken) {
|
||||
return "missing";
|
||||
}
|
||||
|
||||
return `sha256:${createHash("sha256").update(normalizedToken, "utf8").digest("hex").slice(0, 12)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user