A local nutrition tracking app powered by AI
✅ Implementation Status: **Completed & Stable
YuHeng supports multiple user profiles on the same device, ideal for family or shared household use. Each user has their own isolated data, settings, and nutritional targets.
**What’s implemented:
users table and user_id foreign keys on all tablesUserContext class with automatic user filtering in all queriesEach user’s data is fully separated:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE, -- for future SSO
password_hash TEXT, -- bcrypt hash (required in multi-user mode)
avatar TEXT, -- emoji or color
is_active BOOLEAN DEFAULT true,
is_default BOOLEAN DEFAULT false,
role TEXT DEFAULT 'user', -- user, admin (for future SSO)
created_at TIMESTAMP DEFAULT NOW(),
last_login_at TIMESTAMP,
sso_provider TEXT, -- oauth, oidc, etc. (future)
sso_id TEXT -- external user ID (future)
);
-- Default system user for migration (no password - single-user mode)
INSERT INTO users (id, name, is_default)
VALUES ('00000000-0000-0000-0000-000000000000', 'Default', true);
Global setting controls behavior:
-- When enabled: show login screen, require password
INSERT INTO settings (key, value)
VALUES ('multi_user_enabled', 'false');
Single-User Mode (default):
00000000-0000-0000-0000-000000000000Multi-User Mode:
Add user_id column with foreign key constraint:
-- recipes
ALTER TABLE recipes ADD COLUMN user_id UUID
DEFAULT '00000000-0000-0000-0000-000000000000'
REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_recipes_user_id ON recipes(user_id);
-- entries
ALTER TABLE entries ADD COLUMN user_id UUID
DEFAULT '00000000-0000-0000-0000-000000000000'
REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_entries_user_id ON entries(user_id);
-- dishes
ALTER TABLE dishes ADD COLUMN user_id UUID
DEFAULT '00000000-0000-0000-0000-000000000000'
REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_dishes_user_id ON dishes(user_id);
-- settings (composite primary key)
ALTER TABLE settings RENAME TO settings_old;
CREATE TABLE settings (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (user_id, key)
);
CREATE INDEX idx_settings_user_id ON settings(user_id);
-- migration: copy old settings to default user
INSERT INTO settings (user_id, key, value)
SELECT '00000000-0000-0000-0000-000000000000', key, value
FROM settings_old;
DROP TABLE settings_old;
-- recognition_tasks
ALTER TABLE recognition_tasks ADD COLUMN user_id UUID
DEFAULT '00000000-0000-0000-0000-000000000000'
REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_recognition_tasks_user_id ON recognition_tasks(user_id);
// lib/db/user-context.ts
export class UserContext {
private static currentUserId: string = '00000000-0000-0000-0000-000000000000';
static set(userId: string): void {
this.currentUserId = userId;
}
static get(): string {
return this.currentUserId;
}
static isDefault(): boolean {
return this.currentUserId === '00000000-0000-0000-0000-000000000000';
}
}
All queries automatically filter by user_id:
// All queries include user_id filter
async getRecipe(name: string): Promise<Recipe | undefined> {
const rows = await this.sql`
SELECT * FROM recipes
WHERE name = ${name}
AND user_id = ${UserContext.get()}
`;
return rows[0] as Recipe | undefined;
}
async createRecipe(recipe: Omit<Recipe, 'id' | 'created_at'>): Promise<Recipe> {
const rows = await this.sql`
INSERT INTO recipes (name, energy, ..., user_id)
VALUES (${recipe.name}, ..., ${UserContext.get()})
RETURNING id
`;
return { ...recipe, id: rows[0].id } as Recipe;
}
Settings become user-scoped with fallback to global defaults:
async getSetting(key: string): Promise<string | undefined> {
// First try user-specific
const rows = await this.sql`
SELECT value FROM settings
WHERE key = ${key}
AND user_id = ${UserContext.get()}
`;
if (rows.length > 0) return rows[0].value;
// Fallback to default user (global defaults)
const fallback = await this.sql`
SELECT value FROM settings
WHERE key = ${key}
AND user_id = '00000000-0000-0000-0000-000000000000'
`;
return fallback[0]?.value;
}
┌─────────────────────────────────┐
│ 👤 Alice ▼ │ ← Header dropdown
├─────────────────────────────────┤
│ 👤 Alice ✓ │
│ 👤 Bob │
│ 👤 Charlie │
│ ────────────────────────────── │
│ ➕ Add Profile │
│ ⚙️ Manage Profiles │
└─────────────────────────────────┘
Authentication Flow:
// lib/auth/auth.ts
export class AuthService {
static async login(userId: string, password: string): Promise<boolean> {
const user = await db.getUser(userId);
if (!user?.password_hash) return false;
return bcrypt.compare(password, user.password_hash);
}
static async setPassword(userId: string, password: string): Promise<void> {
const hash = await bcrypt.hash(password, 12);
await db.updateUser(userId, { password_hash: hash });
}
static async enableMultiUserMode(adminPassword: string): Promise<void> {
// 1. Set password for default user
await this.setPassword(DEFAULT_USER_ID, adminPassword);
// 2. Toggle multi-user mode setting
await db.saveSetting('multi_user_enabled', 'true');
}
}
Session State:
localStorage sessionEnabling Multi-User Mode:
user_id columns with default value to all tablesusers table and insert default userUserContext classuser_id// Authentication
POST /api/auth/login Login with user ID + password
POST /api/auth/logout Clear session
POST /api/auth/enable Enable multi-user mode (set admin password)
POST /api/auth/disable Disable multi-user mode (admin only)
GET /api/auth/status Get auth mode and current session
// User management
GET /api/users List all users (name, avatar only)
POST /api/users Create user (admin only)
PATCH /api/users/:id Update user (admin or self)
DELETE /api/users/:id Delete user (admin only)
POST /api/users/:id/password Set/change password (admin or self)
// Session
GET /api/users/me Get current user profile
00000000-0000-0000-0000-000000000000 user cannot be deletedON DELETE CASCADE ensures all user data is removed when user is deletedis_shared: true with user_id IS NULL for global recipesCurrent architecture supports future SSO integration:
email field for user identificationsso_provider and sso_id fields for external authrole field) ready for admin/user separationFuture SSO Integration Path:
users table migrationuser_id column to recipes, entries, dishes, recognition_taskssettings to composite primary key (user_id, key)user_id columnsmulti_user_enabled global setting (default: false)UserContext class for current user trackingIDatabaseAdapter interface with user management methodsPostgresAdapter with user_id filterbcrypt for password hashingAuthService class for login/password management/api/auth/* endpoints (login, logout, enable/disable multi-user)/api/users/* CRUD endpointsUserContext always uses default user