feat(backend): add intake journal APIs and share note support

This commit is contained in:
Daniel Volz
2026-05-24 13:36:25 +02:00
committed by GitHub
parent 767ae23843
commit e4a1b449c6
28 changed files with 6384 additions and 281 deletions
@@ -0,0 +1,15 @@
CREATE TABLE `intake_journal` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_tracking_id` integer NOT NULL,
`medication_id` integer NOT NULL,
`scheduled_for` integer NOT NULL,
`note` text NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
@@ -0,0 +1 @@
ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -106,6 +106,20 @@
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1778962021119,
"tag": "0015_add_intake_journal",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1779044316043,
"tag": "0016_add_share_allow_journal_notes",
"breakpoints": true
}
]
}
+12
View File
@@ -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)`,
+1
View File
@@ -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
+22
View File
@@ -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
// =============================================================================
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+373
View File
@@ -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 };
}
);
}
+28 -5
View File
@@ -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",
+92 -9
View File
@@ -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
View File
@@ -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,
}));
}
+240 -6
View File
@@ -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");
});
});
});
+199 -14
View File
@@ -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" })]);
});
});
+30 -10
View File
@@ -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
+71
View File
@@ -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";
+20
View File
@@ -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");
});
});
+71 -3
View File
@@ -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({
+12 -4
View File
@@ -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;
+17
View File
@@ -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("");
}
+10
View File
@@ -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)}`;
}
+2 -5
View File
@@ -7,11 +7,8 @@ export default defineConfig({
include: ["src/**/*.test.ts"],
setupFiles: ["src/test/setup.ts"],
// Run tests sequentially to avoid DB conflicts
poolOptions: {
threads: {
singleThread: true,
},
},
fileParallelism: false,
maxWorkers: 1,
// Timeout for longer integration tests
testTimeout: 10000,
coverage: {