diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index 7f9647b..cbc6295 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { extname, resolve } from "node:path"; import { eq } from "drizzle-orm"; -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { getDataDir } from "../db/db-utils.js"; -import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js"; +import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; @@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images"); // ============================================================================= // Export Format Version (bump this when format changes) // ============================================================================= -const EXPORT_VERSION = "1.0"; +const EXPORT_VERSION = "1.1"; // ============================================================================= // Zod Schemas for Import Validation @@ -35,6 +35,7 @@ const inventorySchema = z.object({ packCount: z.number().int().min(0).default(1), blistersPerPack: z.number().int().min(1).default(1), pillsPerBlister: z.number().int().min(1).default(1), + totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity looseTablets: z.number().int().min(0).default(0), stockAdjustment: z.number().int().default(0), // Manual stock correction packageType: z.enum(["blister", "bottle"]).default("blister"), @@ -60,6 +61,7 @@ const medicationExportSchema = z.object({ prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(), prescriptionLowRefillThreshold: z.number().int().min(0).default(1), prescriptionExpiryDate: z.string().nullable().optional(), + dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses image: z.string().nullable().optional(), // base64 data URL or null lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction }); @@ -74,6 +76,14 @@ const doseHistorySchema = z.object({ takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel") }); +const refillHistoryExportSchema = z.object({ + medicationRef: z.string(), // References _exportId + packsAdded: z.number().int().min(0).default(0), + loosePillsAdded: z.number().int().min(0).default(0), + usedPrescription: z.boolean().default(false), + refillDate: z.string(), // ISO datetime +}); + const shareLinkSchema = z.object({ takenBy: z.string().min(1), scheduleDays: z.number().int().min(1).default(30), @@ -106,9 +116,11 @@ const settingsExportSchema = z lowStockDays: z.number().int().default(30), normalStockDays: z.number().int().default(90), highStockDays: z.number().int().default(180), + expiryWarningDays: z.number().int().default(90), // UI preferences language: z.string().default("en"), stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"), + shareStockStatus: z.boolean().default(true), }) .optional(); @@ -118,6 +130,7 @@ const importDataSchema = z.object({ includeSensitiveData: z.boolean().default(false), medications: z.array(medicationExportSchema).default([]), doseHistory: z.array(doseHistorySchema).default([]), + refillHistory: z.array(refillHistoryExportSchema).default([]), settings: settingsExportSchema, shareLinks: z.array(shareLinkSchema).default([]), }); @@ -127,7 +140,7 @@ const importDataSchema = z.object({ // ============================================================================= // Helper to get user ID from request -async function getUserId(request: any, reply: any): Promise { +async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } @@ -285,6 +298,7 @@ export async function exportRoutes(app: FastifyInstance) { packCount: med.packCount ?? 1, blistersPerPack: med.blistersPerPack ?? 1, pillsPerBlister: med.pillsPerBlister ?? 1, + totalPills: med.totalPills ?? null, looseTablets: med.looseTablets ?? 0, stockAdjustment: med.stockAdjustment ?? 0, packageType: med.packageType ?? "blister", @@ -303,6 +317,7 @@ export async function exportRoutes(app: FastifyInstance) { prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null, prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, prescriptionExpiryDate: med.prescriptionExpiryDate ?? null, + dismissedUntil: med.dismissedUntil ?? null, image: includeImages ? imageToBase64(med.imageUrl) : null, lastStockCorrectionAt: lastStockCorrectionAtIso, }; @@ -380,8 +395,10 @@ export async function exportRoutes(app: FastifyInstance) { lowStockDays: settings.lowStockDays, normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, + expiryWarningDays: settings.expiryWarningDays, language: settings.language, stockCalculationMode: settings.stockCalculationMode, + shareStockStatus: settings.shareStockStatus, } : undefined; @@ -412,6 +429,39 @@ export async function exportRoutes(app: FastifyInstance) { }; }); + // 5. Load refill history + const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId)); + + const exportRefillHistory = refills + .map((refill) => { + const exportId = medIdToExportId.get(refill.medicationId); + if (!exportId) return null; // Orphaned refill, skip + + // Safely convert refillDate to ISO string + let refillDateIso: string; + try { + if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) { + refillDateIso = refill.refillDate.toISOString(); + } else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") { + const d = new Date(refill.refillDate); + refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); + } else { + refillDateIso = new Date().toISOString(); + } + } catch { + refillDateIso = new Date().toISOString(); + } + + return { + medicationRef: exportId, + packsAdded: refill.packsAdded ?? 0, + loosePillsAdded: refill.loosePillsAdded ?? 0, + usedPrescription: refill.usedPrescription ?? false, + refillDate: refillDateIso, + }; + }) + .filter((r): r is NonNullable => r !== null); + // Build export object const exportData = { version: EXPORT_VERSION, @@ -419,6 +469,7 @@ export async function exportRoutes(app: FastifyInstance) { includeSensitiveData: includeSensitive, medications: exportMedications, doseHistory: exportDoseHistory, + refillHistory: exportRefillHistory, settings: exportSettings, shareLinks: exportShareLinks, }; @@ -475,7 +526,8 @@ export async function exportRoutes(app: FastifyInstance) { } } - // Delete in order: doses, share tokens, medications, settings + // 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)); @@ -517,6 +569,7 @@ export async function exportRoutes(app: FastifyInstance) { 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, @@ -536,6 +589,7 @@ export async function exportRoutes(app: FastifyInstance) { 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(); @@ -594,8 +648,10 @@ export async function exportRoutes(app: FastifyInstance) { 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", + shareStockStatus: importData.settings.shareStockStatus ?? true, }); } @@ -613,11 +669,27 @@ export async function exportRoutes(app: FastifyInstance) { }); } + // 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 ?? 0, + usedPrescription: refill.usedPrescription ?? false, + refillDate: new Date(refill.refillDate), + }); + } + return { success: true, imported: { medications: importData.medications.length, doseHistory: importData.doseHistory.length, + refillHistory: importData.refillHistory.length, settings: importData.settings ? 1 : 0, shareLinks: importData.shareLinks.length, }, diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index bbb813f..19cc60a 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -175,8 +175,20 @@ export interface AppContextValue { setShowImportConfirm: React.Dispatch>; pendingImportData: unknown; setPendingImportData: React.Dispatch>; - importResult: { medications: number; doses: number; shares: number } | null; - setImportResult: React.Dispatch>; + importResult: { + medications: number; + doses: number; + refills: number; + shares: number; + } | null; + setImportResult: React.Dispatch< + React.SetStateAction<{ + medications: number; + doses: number; + refills: number; + shares: number; + } | null> + >; handleExport: (includeImages?: boolean) => Promise; handleImportFileSelect: (e: React.ChangeEvent) => void; handleImportConfirm: () => Promise; @@ -237,7 +249,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const [showExportModal, setShowExportModal] = useState(false); const [showImportConfirm, setShowImportConfirm] = useState(false); const [pendingImportData, setPendingImportData] = useState(null); - const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null); + const [importResult, setImportResult] = useState<{ + medications: number; + doses: number; + refills: number; + shares: number; + } | null>(null); // Load user-specific scheduleDays when user changes useEffect(() => { @@ -581,6 +598,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { setImportResult({ medications: data.imported?.medications || 0, doses: data.imported?.doseHistory || 0, + refills: data.imported?.refillHistory || 0, shares: data.imported?.shareLinks || 0, }); diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 8a3ec79..dab1505 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -501,7 +501,7 @@ "cancelButton": "Abbrechen", "exportSuccess": "Daten erfolgreich exportiert", "importSuccess": "Daten erfolgreich importiert", - "importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links", + "importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links", "importError": "Daten konnten nicht importiert werden", "invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.", "downloadFilename": "medassist-export" diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 2624631..e94ebad 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -501,7 +501,7 @@ "cancelButton": "Cancel", "exportSuccess": "Data exported successfully", "importSuccess": "Data imported successfully", - "importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links", + "importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links", "importError": "Failed to import data", "invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.", "downloadFilename": "medassist-export" diff --git a/frontend/src/test/context/AppContext.test.tsx b/frontend/src/test/context/AppContext.test.tsx index 0fbcd1c..d779792 100644 --- a/frontend/src/test/context/AppContext.test.tsx +++ b/frontend/src/test/context/AppContext.test.tsx @@ -256,7 +256,7 @@ describe("useAppContext", () => { (global.fetch as ReturnType).mockResolvedValue({ ok: true, json: () => Promise.resolve({}), - text: () => Promise.resolve('{"imported":{"medications":1,"doseHistory":2,"shareLinks":3}}'), + text: () => Promise.resolve('{"imported":{"medications":1,"doseHistory":2,"refillHistory":4,"shareLinks":3}}'), }); }); @@ -364,7 +364,7 @@ describe("useAppContext", () => { expect(mockUseMedications().loadMeds).toHaveBeenCalled(); expect(mockUseSettings().loadSettings).toHaveBeenCalled(); expect(mockUseDoses().loadTakenDoses).toHaveBeenCalled(); - expect(result.current.importResult).toEqual({ medications: 1, doses: 2, shares: 3 }); + expect(result.current.importResult).toEqual({ medications: 1, doses: 2, refills: 4, shares: 3 }); }); it("exports data and triggers JSON download", async () => {