feat: add expiration date to share tokens and enhance error handling for expired links
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
+62
-3
@@ -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<SharedScheduleData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(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<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(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 (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-error expired">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<div className="expired-icon">⏰</div>
|
||||
<h2>{t('share.expired.title')}</h2>
|
||||
<p className="expired-message">
|
||||
{t('share.expired.message', { takenBy: expiredData.takenBy })}
|
||||
</p>
|
||||
<p className="expired-contact">
|
||||
{t('share.expired.contact', { username: expiredData.ownerUsername })}
|
||||
</p>
|
||||
<p className="expired-date">
|
||||
{t('share.expired.expiredOn', { date: new Date(expiredData.expiredAt).toLocaleDateString(i18n.language) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
@@ -3514,6 +3568,11 @@ function SharedSchedule() {
|
||||
<div className="shared-schedule-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>💊 {t('share.scheduleFor')} {data.takenBy}</h1>
|
||||
<div className="shared-schedule-header-actions">
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="shared-schedule-period">
|
||||
{t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')}
|
||||
</p>
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user