feat: migrate taken_by to taken_by_json for multi-person support

- Added `taken_by_json` column to `medications` table to store an array of names.
- Updated migration scripts to convert existing `taken_by` data into JSON format.
- Modified backend routes to handle the new `taken_by_json` structure, including parsing and filtering logic.
- Updated frontend to support multi-value input for "Taken By" using tags.
- Adjusted validation and state management for the new array format in forms.
- Enhanced UI for displaying multiple names and added autocomplete suggestions for input.
- Updated translations for input placeholders to reflect new functionality.
- Added CSS styles for tag input components.
This commit is contained in:
Daniel Volz
2025-12-28 18:22:32 +01:00
parent abffd66e9c
commit 4a6aab338f
11 changed files with 292 additions and 77 deletions
+1
View File
@@ -32,6 +32,7 @@ async function main() {
name text NOT NULL,
generic_name text,
taken_by text,
taken_by_json text NOT NULL DEFAULT '[]',
count integer NOT NULL DEFAULT 0,
strips integer NOT NULL DEFAULT 0,
pack_count integer NOT NULL DEFAULT 1,
@@ -0,0 +1,17 @@
-- Convert taken_by from single string to JSON array
-- This allows multiple people to share the same medication
-- Add new column for JSON array
ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]';
-- Migrate existing data: convert single string to JSON array
-- If taken_by is not null/empty, convert to ["value"], otherwise keep as []
UPDATE medications
SET taken_by_json = CASE
WHEN taken_by IS NOT NULL AND taken_by != ''
THEN json_array(taken_by)
ELSE '[]'
END;
-- Note: We keep the old taken_by column for backwards compatibility during migration
-- It can be dropped in a future migration once all code uses taken_by_json
+2 -1
View File
@@ -15,6 +15,7 @@
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false },
{ "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false },
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false },
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false }
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false },
{ "idx": 16, "version": 1, "when": 1735400002, "tag": "0016_taken_by_json_array", "breakpoint": false }
]
}
+2 -1
View File
@@ -25,7 +25,8 @@ export const medications = sqliteTable("medications", {
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }),
takenBy: text("taken_by", { length: 100 }),
takenBy: text("taken_by", { length: 100 }), // Deprecated: use takenByJson
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
count: integer("count").notNull().default(0),
strips: integer("strips").notNull().default(0),
packCount: integer("pack_count").notNull().default(1),
+20 -6
View File
@@ -21,7 +21,7 @@ const blisterSchema = z.object({
const medicationSchema = z.object({
name: z.string().trim().min(1).max(100),
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names
packCount: z.number().int().min(0).default(1),
stripsPerPack: z.number().int().min(1).default(1),
tabsPerStrip: z.number().int().min(1).default(1),
@@ -53,6 +53,16 @@ function parseBlisters(row: typeof medications.$inferSelect) {
}
}
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
export async function medicationRoutes(app: FastifyInstance) {
// All medication routes require auth
app.addHook("preHandler", requireAuth);
@@ -81,7 +91,7 @@ export async function medicationRoutes(app: FastifyInstance) {
id: row.id,
name: row.name,
genericName: row.genericName,
takenBy: row.takenBy,
takenBy: parseTakenByJson(row.takenByJson),
count: row.count,
strips: row.strips,
stripSize: row.stripSize,
@@ -108,6 +118,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []);
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
@@ -117,7 +128,8 @@ export async function medicationRoutes(app: FastifyInstance) {
userId,
name,
genericName: genericName || null,
takenBy: takenBy || null,
takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat
takenByJson,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
@@ -139,7 +151,7 @@ export async function medicationRoutes(app: FastifyInstance) {
id: inserted.id,
name: inserted.name,
genericName: inserted.genericName,
takenBy: inserted.takenBy,
takenBy: parseTakenByJson(inserted.takenByJson),
count: inserted.count,
strips: inserted.strips,
stripSize: inserted.stripSize,
@@ -173,6 +185,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []);
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
@@ -181,7 +194,8 @@ export async function medicationRoutes(app: FastifyInstance) {
.set({
name,
genericName: genericName || null,
takenBy: takenBy || null,
takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat
takenByJson,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
@@ -207,7 +221,7 @@ export async function medicationRoutes(app: FastifyInstance) {
id: result[0].id,
name: result[0].name,
genericName: result[0].genericName,
takenBy: result[0].takenBy,
takenBy: parseTakenByJson(result[0].takenByJson),
count: result[0].count,
strips: result[0].strips,
stripSize: result[0].stripSize,
+39 -20
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -35,6 +35,17 @@ async function getUserId(request: any, reply: any): Promise<number> {
return authUser.id;
}
// Helper to parse takenByJson
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// =============================================================================
// Share Routes
// =============================================================================
@@ -70,13 +81,15 @@ export async function shareRoutes(app: FastifyInstance) {
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Get medications for this user filtered by takenBy
const meds = await db.select().from(medications).where(
and(
eq(medications.userId, share.userId),
eq(medications.takenBy, share.takenBy)
)
);
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
// Filter medications where takenByJson array contains the share.takenBy value
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
return takenByArray.includes(share.takenBy);
});
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
@@ -135,15 +148,14 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy
const [existingMed] = await db.select().from(medications).where(
and(
eq(medications.userId, userId),
eq(medications.takenBy, takenBy)
)
);
// Check if user has medications for this takenBy (search in JSON array)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
return takenByArray.includes(takenBy);
});
if (!existingMed) {
if (medsForPerson.length === 0) {
return reply.status(400).send({
error: "No medications found for this person",
code: "NO_MEDICATIONS",
@@ -182,14 +194,21 @@ export async function shareRoutes(app: FastifyInstance) {
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user
const meds = await db.select({ takenBy: medications.takenBy })
// Get all unique takenBy values for this user (from JSON arrays)
const meds = await db.select({ takenByJson: medications.takenByJson })
.from(medications)
.where(eq(medications.userId, userId));
const uniquePeople = [...new Set(meds.map((m) => m.takenBy).filter(Boolean))] as string[];
// Collect all unique person names from all takenByJson arrays
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
for (const person of takenByArray) {
if (person) allPeople.add(person);
}
}
return { people: uniquePeople };
return { people: [...allPeople].sort() };
}
);
}
@@ -22,6 +22,17 @@ function getTimezone(): string {
return process.env.TZ || "UTC";
}
// Parse takenByJson to array of strings
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
function loadIntakeReminderState(): IntakeReminderState {
@@ -63,11 +74,11 @@ type UpcomingIntake = {
usage: number;
intakeTime: Date;
intakeTimeStr: string;
takenBy: string | null;
takenBy: string[]; // Changed to array
pillWeightMg: number | null;
};
function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string | null, pillWeightMg: number | null, locale: string): UpcomingIntake[] {
function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string[], pillWeightMg: number | null, locale: string): UpcomingIntake[] {
const now = Date.now();
// Window to detect if "now" is the right time to send reminder
// We check if the notify time (intake - 15min) falls within current minute ±1
@@ -153,10 +164,11 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
return pillText;
};
// Helper to format medication name with takenBy
// Helper to format medication name with takenBy (array of names)
const formatMedName = (intake: UpcomingIntake): string => {
if (intake.takenBy) {
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}</span>`;
if (intake.takenBy.length > 0) {
const namesStr = intake.takenBy.join(", ");
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: namesStr })}</span>`;
}
return intake.medName;
};
@@ -226,7 +238,7 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
${intakes.map((i) => {
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
}).join("\n")}
@@ -304,7 +316,8 @@ async function checkAndSendIntakeRemindersForUser(
// Find all upcoming intakes across all medications for this user
for (const med of medsWithReminders) {
const blisters = parseBlisters(med);
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale);
const takenByArray = parseTakenByJson(med.takenByJson);
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale);
allUpcoming.push(...upcoming);
}
@@ -343,7 +356,7 @@ async function checkAndSendIntakeRemindersForUser(
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
const message = newReminders
.map((i) => {
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
if (i.pillWeightMg) {
const totalMg = i.usage * i.pillWeightMg;