diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ef1544f..ef8d508 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -209,7 +209,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp ## ⚠️ Database Migrations (CRITICAL) -**When adding/modifying database columns, ALWAYS:** +**When adding/modifying database columns or tables, ALWAYS do ALL of the following:** 1. **Update schema**: `backend/src/db/schema.ts` 2. **Create migration file**: `backend/src/db/migrations/XXXX_description.sql` @@ -221,8 +221,15 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp ```json { "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false } ``` +4. **Update migrate.ts**: `backend/src/db/migrate.ts` + - This file contains `CREATE TABLE IF NOT EXISTS` statements for fresh database starts + - Add new columns to the relevant table or add new tables + - Without this, fresh installs will be missing the new columns/tables! -**Why this matters**: The dev database might get updated manually, but production will break without proper migration files. This causes `SQLITE_ERROR: no such column` errors in prod. +**Why this matters**: +- Migration SQL files: Required for upgrading existing databases +- migrate.ts: Required for fresh database starts (creates tables with all columns) +- Forgetting either causes `SQLITE_ERROR: no such column` errors ## File Locations diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 0ab5aa3..782a2c1 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -17,7 +17,9 @@ async function main() { id integer PRIMARY KEY AUTOINCREMENT, username text NOT NULL UNIQUE, password_hash text, + avatar_url text, auth_provider text NOT NULL DEFAULT 'local', + oidc_subject text, is_active integer NOT NULL DEFAULT 1, last_login_at integer, created_at integer NOT NULL DEFAULT (strftime('%s','now')), @@ -66,6 +68,7 @@ async function main() { normal_stock_days integer NOT NULL DEFAULT 90, high_stock_days integer NOT NULL DEFAULT 180, language text NOT NULL DEFAULT 'en', + stock_calculation_mode text NOT NULL DEFAULT 'automatic', last_auto_email_sent text, last_notification_type text, last_notification_channel text, @@ -91,6 +94,16 @@ async function main() { taken_by text NOT NULL, schedule_days integer NOT NULL DEFAULT 30, created_at integer NOT NULL DEFAULT (strftime('%s','now')), + expires_at integer, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS dose_tracking ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + dose_id text NOT NULL, + taken_at integer NOT NULL DEFAULT (strftime('%s','now')), + marked_by text, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `; diff --git a/backend/src/db/migrations/0015_add_share_token_expiry.sql b/backend/src/db/migrations/0015_add_share_token_expiry.sql new file mode 100644 index 0000000..37a16dd --- /dev/null +++ b/backend/src/db/migrations/0015_add_share_token_expiry.sql @@ -0,0 +1,3 @@ +-- Add expiration date to share tokens (default 1 year from creation) +-- NULL means no expiration (for backwards compatibility with existing tokens) +ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index 3842cad..d796e44 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -14,6 +14,7 @@ { "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }, { "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": 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 } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 412bcb9..2f192cc 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -102,6 +102,7 @@ export const shareTokens = sqliteTable("share_tokens", { takenBy: text("taken_by", { length: 100 }).notNull(), scheduleDays: integer("schedule_days").notNull().default(30), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), + expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires }); // ============================================================================= diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 7cb7604..4c1f13a 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -2,12 +2,15 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; import { randomBytes } from "crypto"; import { db } from "../db/client.js"; -import { medications, shareTokens, userSettings } from "../db/schema.js"; +import { medications, shareTokens, userSettings, users } from "../db/schema.js"; import { eq, and } from "drizzle-orm"; import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +// Share token validity: 1 year in milliseconds +const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000; + // ============================================================================= // Validation Schemas // ============================================================================= @@ -45,7 +48,23 @@ export async function shareRoutes(app: FastifyInstance) { // Find share token const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); if (!share) { - return reply.notFound("Share link not found"); + return reply.status(404).send({ + error: "Share link not found", + code: "NOT_FOUND" + }); + } + + // Check if token has expired + if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { + // 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)); + return reply.status(410).send({ + error: "Share link has expired", + code: "EXPIRED", + ownerUsername: owner?.username ?? "the owner", + takenBy: share.takenBy, + expiredAt: share.expiresAt.toISOString(), + }); } // Get user settings for stock thresholds @@ -133,6 +152,9 @@ export async function shareRoutes(app: FastifyInstance) { // Generate unique token (8 bytes = 16 hex chars) const token = randomBytes(8).toString("hex"); + + // Set expiration date (1 year from now) + const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS); // Create share token await db.insert(shareTokens).values({ @@ -140,11 +162,13 @@ export async function shareRoutes(app: FastifyInstance) { token, takenBy, scheduleDays, + expiresAt, }); return { token, shareUrl: `/share/${token}`, + expiresAt: expiresAt.toISOString(), }; } ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f86b2ec..59a5f52 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3181,15 +3181,38 @@ type SharedScheduleData = { }; }; +type ExpiredLinkData = { + ownerUsername: string; + takenBy: string; + expiredAt: string; +}; + function SharedSchedule() { const { token } = useParams<{ token: string }>(); const { t, i18n } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [expiredData, setExpiredData] = useState(null); const [takenDoses, setTakenDoses] = useState>(new Set()); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [showPastDays, setShowPastDays] = useState(false); + const [theme, setTheme] = useState<"light" | "dark">(() => { + if (typeof window !== "undefined") { + return (localStorage.getItem("theme") as "light" | "dark") || "dark"; + } + return "dark"; + }); + + // Apply theme to document + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); + }, [theme]); + + function toggleTheme() { + setTheme((prev) => (prev === "dark" ? "light" : "dark")); + } // Collapsed days state for SharedSchedule (token-specific localStorage) const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); @@ -3332,17 +3355,27 @@ function SharedSchedule() { if (res.ok) { const json = await res.json(); setData(json); + } else if (res.status === 410) { + // Link expired - get owner info + const json = await res.json(); + setExpiredData({ + ownerUsername: json.ownerUsername, + takenBy: json.takenBy, + expiredAt: json.expiredAt, + }); + } else if (res.status === 404) { + setError(t('share.notFound')); } else { - setError("Share link not found or expired"); + setError(t('share.error')); } } catch { - setError("Failed to load schedule"); + setError(t('share.error')); } finally { setLoading(false); } } fetchData(); - }, [token]); + }, [token, t]); // Build schedule from medications const schedule = useMemo(() => { @@ -3498,6 +3531,27 @@ function SharedSchedule() { ); } + if (expiredData) { + return ( +
+
+

💊 MedAssist

+
+

{t('share.expired.title')}

+

+ {t('share.expired.message', { takenBy: expiredData.takenBy })} +

+

+ {t('share.expired.contact', { username: expiredData.ownerUsername })} +

+

+ {t('share.expired.expiredOn', { date: new Date(expiredData.expiredAt).toLocaleDateString(i18n.language) })} +

+
+
+ ); + } + if (error || !data) { return (
@@ -3514,6 +3568,11 @@ function SharedSchedule() {

💊 {t('share.scheduleFor')} {data.takenBy}

+
+ +

{t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')}

diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 8985670..ea68df7 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -330,6 +330,14 @@ "scheduleFor": "Zeitplan für", "period": "Zeitraum", "noSchedule": "Keine geplanten Einnahmen gefunden.", - "generatedBy": "Erstellt von" + "generatedBy": "Erstellt von", + "notFound": "Teilen-Link nicht gefunden", + "error": "Zeitplan konnte nicht geladen werden", + "expired": { + "title": "Link abgelaufen", + "message": "Dieser Teilen-Link für den Medikamentenplan von {{takenBy}} ist abgelaufen.", + "contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.", + "expiredOn": "Abgelaufen am: {{date}}" + } } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 78e95c9..0777a9d 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -332,6 +332,14 @@ "scheduleFor": "Schedule for", "period": "Period", "noSchedule": "No scheduled doses found.", - "generatedBy": "Generated by" + "generatedBy": "Generated by", + "notFound": "Share link not found", + "error": "Failed to load schedule", + "expired": { + "title": "Link Expired", + "message": "This share link for {{takenBy}}'s medication schedule has expired.", + "contact": "Please contact {{username}} to request a new link.", + "expiredOn": "Expired on: {{date}}" + } } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a105f6c..209d331 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3581,11 +3581,59 @@ h3 .reminder-icon.info-tooltip { font-size: 1.125rem; } +/* Expired link styling */ +.shared-schedule-error.expired { + max-width: 500px; + margin: 0 auto; +} + +.shared-schedule-error .expired-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.shared-schedule-error.expired h2 { + font-size: 1.5rem; + color: var(--warning); + margin-bottom: 1.5rem; +} + +.shared-schedule-error .expired-message { + font-size: 1.125rem; + color: var(--text-primary); + margin-bottom: 1rem; + line-height: 1.5; +} + +.shared-schedule-error .expired-contact { + font-size: 1rem; + color: var(--text-secondary); + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: 8px; + border: 1px solid var(--border-primary); +} + +.shared-schedule-error .expired-date { + font-size: 0.875rem; + color: var(--text-muted); +} + .shared-schedule-header { text-align: center; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-primary); + position: relative; +} + +.shared-schedule-header-actions { + position: absolute; + top: 0; + right: 0; + display: flex; + gap: 0.5rem; } .shared-schedule-header h1 {