feat: add user avatar functionality and update related routes
- Implemented avatar upload and deletion in the Auth context. - Updated UserProfile component to handle avatar display and actions. - Modified backend routes to return anonymous user ID when auth is disabled. - Added avatar_url column to users table in the database. - Enhanced UI for user menu and profile modal to support avatar display. - Updated translations for new avatar-related strings. - Improved stock status calculation for medications in the planner.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Add avatar URL column to users table
|
||||
ALTER TABLE users ADD COLUMN avatar_url TEXT;
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false },
|
||||
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false },
|
||||
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false },
|
||||
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }
|
||||
{ "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 }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username", { length: 100 }).notNull().unique(),
|
||||
passwordHash: text("password_hash", { length: 255 }),
|
||||
avatarUrl: text("avatar_url", { length: 255 }),
|
||||
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
|
||||
|
||||
@@ -2,7 +2,45 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { env } from "./env.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { sql, count } from "drizzle-orm";
|
||||
import { sql, count, eq } from "drizzle-orm";
|
||||
|
||||
// =============================================================================
|
||||
// Anonymous User - Used when AUTH_ENABLED=false
|
||||
// Uses a fixed high ID (999999999) to never collide with regular users
|
||||
// =============================================================================
|
||||
const ANONYMOUS_USER_ID = 999999999;
|
||||
const ANONYMOUS_USERNAME = "__anonymous__";
|
||||
let anonymousUserVerified = false;
|
||||
|
||||
/**
|
||||
* Get or create the anonymous user for no-auth mode.
|
||||
* Uses a fixed ID (999999999) that will never collide with auto-increment IDs.
|
||||
*/
|
||||
export async function getAnonymousUserId(): Promise<number> {
|
||||
// Return cached if already verified
|
||||
if (anonymousUserVerified) {
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// Check if anonymous user exists
|
||||
const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID));
|
||||
|
||||
if (existing) {
|
||||
anonymousUserVerified = true;
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// Create anonymous user with fixed ID (SQLite allows explicit ID)
|
||||
await db.run(sql`
|
||||
INSERT INTO users (id, username, password_hash, auth_provider, is_active, created_at, updated_at)
|
||||
VALUES (${ANONYMOUS_USER_ID}, ${ANONYMOUS_USERNAME}, NULL, 'anonymous', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
anonymousUserVerified = true;
|
||||
console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`);
|
||||
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth State - Computed at runtime
|
||||
@@ -16,7 +54,8 @@ export interface AuthState {
|
||||
}
|
||||
|
||||
export async function getAuthState(): Promise<AuthState> {
|
||||
const [result] = await db.select({ count: count() }).from(users);
|
||||
// Count only real users (not the anonymous user with fixed ID)
|
||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||
const hasUsers = result.count > 0;
|
||||
|
||||
return {
|
||||
|
||||
@@ -329,6 +329,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
@@ -385,4 +386,82 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
return { ok: true, message: "Profile updated" };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/avatar - Upload user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/avatar", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
|
||||
// Save file
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const imagesDir = path.join(process.cwd(), "data", "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
|
||||
return { ok: true, avatarUrl: filename };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /auth/avatar - Delete user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete("/auth/avatar", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user?.avatarUrl) {
|
||||
return reply.status(404).send({ error: "No avatar to delete" });
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
try {
|
||||
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { eq, and, gte } from "drizzle-orm";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -19,11 +19,11 @@ const shareDoseSchema = z.object({
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -45,7 +45,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get doses from last 30 days (to avoid loading too much data)
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -76,7 +76,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = markDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -119,7 +119,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
"/doses/taken/:doseId",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const { doseId } = request.params;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { createWriteStream, existsSync, unlinkSync } from "fs";
|
||||
import { resolve, extname } from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -58,11 +58,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -75,7 +75,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -103,7 +103,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const parsed = medicationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
|
||||
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
|
||||
const everyJson = JSON.stringify(blisters.map((s) => s.every));
|
||||
@@ -163,7 +163,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
@@ -229,7 +229,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Delete associated image if exists (with ownership check)
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
@@ -250,7 +250,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return reply.badRequest("Invalid date range");
|
||||
}
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
@@ -146,11 +146,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -163,7 +163,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Get settings for current user
|
||||
app.get("/settings", async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Update settings for current user
|
||||
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const body = request.body;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { randomBytes } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { requireAuth, optionalAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -17,11 +17,11 @@ const createShareSchema = z.object({
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -104,7 +104,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
"/share",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = createShareSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -156,7 +156,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
"/share/people",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all unique takenBy values for this user
|
||||
const meds = await db.select({ takenBy: medications.takenBy })
|
||||
|
||||
Reference in New Issue
Block a user