Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import cookie from '@fastify/cookie';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { categoryRoutes } from './routes/categories.js';
|
||||
import { recipeRoutes } from './routes/recipes.js';
|
||||
@@ -10,6 +12,7 @@ import { imageRoutes } from './routes/images.js';
|
||||
import { botRoutes } from './routes/bot.js';
|
||||
import { ogScrapeRoutes } from './routes/og-scrape.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { householdRoutes } from './routes/households.js';
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
@@ -17,6 +20,19 @@ export async function buildApp() {
|
||||
await app.register(cors, { origin: true });
|
||||
await app.after();
|
||||
|
||||
// Register JWT plugin (though we handle JWT manually in auth service)
|
||||
await app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production',
|
||||
});
|
||||
await app.after();
|
||||
|
||||
// Register Cookie plugin for refresh tokens
|
||||
await app.register(cookie, {
|
||||
secret: process.env.COOKIE_SECRET || 'your-super-secret-cookie-key-change-in-production',
|
||||
parseOptions: {},
|
||||
});
|
||||
await app.after();
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.after();
|
||||
|
||||
@@ -47,5 +63,8 @@ export async function buildApp() {
|
||||
await app.register(authRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(householdRoutes);
|
||||
await app.after();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
43
backend/src/db/migrations/003_auth.sql
Normal file
43
backend/src/db/migrations/003_auth.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Auth v2 Migration: Users and Authentication
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- User favorites table (replaces is_favorite column approach)
|
||||
CREATE TABLE IF NOT EXISTS user_favorites (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, recipe_id)
|
||||
);
|
||||
|
||||
-- Add user_id to shopping_items (nullable for migration)
|
||||
ALTER TABLE shopping_items ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Add user_id to notes (nullable for migration)
|
||||
ALTER TABLE notes ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Add created_by to recipes (nullable for migration)
|
||||
ALTER TABLE recipes ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_recipe_id ON user_favorites(recipe_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_items_user_id ON shopping_items(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by);
|
||||
|
||||
-- Trigger for updating users.updated_at
|
||||
CREATE TRIGGER IF NOT EXISTS users_update_timestamp AFTER UPDATE ON users BEGIN
|
||||
UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
27
backend/src/db/migrations/004_households.sql
Normal file
27
backend/src/db/migrations/004_households.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Auth v2 Phase 2: Households
|
||||
|
||||
-- Households table
|
||||
CREATE TABLE IF NOT EXISTS households (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
invite_code TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Household members table (many-to-many relationship between users and households)
|
||||
CREATE TABLE IF NOT EXISTS household_members (
|
||||
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (household_id, user_id)
|
||||
);
|
||||
|
||||
-- Add household_id to shopping_items for household shopping lists
|
||||
ALTER TABLE shopping_items ADD COLUMN household_id TEXT REFERENCES households(id) ON DELETE CASCADE;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_households_invite_code ON households(invite_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_household_members_household_id ON household_members(household_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_household_members_user_id ON household_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_items_household_id ON shopping_items(household_id);
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { buildApp } from './app.js';
|
||||
import { runMigrations } from './db/migrate.js';
|
||||
|
||||
|
||||
@@ -1,7 +1,63 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { authService } from '../services/auth.service.js';
|
||||
|
||||
// v2: Auth middleware — currently passes through everything
|
||||
export async function authMiddleware(request: FastifyRequest, _reply: FastifyReply) {
|
||||
// TODO v2: Verify JWT token, set request.user
|
||||
(request as any).user = { id: 'default', name: 'Luna' };
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Required auth middleware - throws error if no valid token
|
||||
export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
error: 'MISSING_TOKEN',
|
||||
message: 'Authorization token required'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
||||
const decoded = authService.verifyAccessToken(token);
|
||||
|
||||
request.user = {
|
||||
id: decoded.sub,
|
||||
email: decoded.email,
|
||||
display_name: decoded.display_name,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
error: 'INVALID_TOKEN',
|
||||
message: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Optional auth middleware - sets user if token is valid, but doesn't fail if missing
|
||||
export async function optionalAuthMiddleware(request: FastifyRequest, _reply: FastifyReply) {
|
||||
try {
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = authService.verifyAccessToken(token);
|
||||
|
||||
request.user = {
|
||||
id: decoded.sub,
|
||||
email: decoded.email,
|
||||
display_name: decoded.display_name,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore invalid tokens in optional auth
|
||||
request.user = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,308 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { authService } from '../services/auth.service.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long'),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long').optional(),
|
||||
avatar_url: z.string().url('Invalid avatar URL').or(z.literal('')).optional(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
current_password: z.string().min(1, 'Current password is required'),
|
||||
new_password: z.string().min(8, 'New password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
const refreshTokenSchema = z.object({
|
||||
refresh_token: z.string().min(1, 'Refresh token is required'),
|
||||
});
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/api/auth/login', async (_request, reply) => {
|
||||
reply.status(501).send({ error: 'not implemented' });
|
||||
// POST /api/auth/register
|
||||
app.post('/api/auth/register', async (request, reply) => {
|
||||
try {
|
||||
const data = registerSchema.parse(request.body);
|
||||
|
||||
const result = await authService.register(data.email, data.password, data.display_name);
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
reply.status(201).send({
|
||||
success: true,
|
||||
user: result.user,
|
||||
access_token: result.tokens.accessToken,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'EMAIL_EXISTS') {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'EMAIL_EXISTS',
|
||||
message: 'An account with this email already exists',
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
const firstError = error.errors && error.errors.length > 0 ? error.errors[0] : null;
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: firstError?.message || 'Invalid data provided',
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Register error:', error);
|
||||
reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Registration failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/register', async (_request, reply) => {
|
||||
reply.status(501).send({ error: 'not implemented' });
|
||||
// POST /api/auth/login
|
||||
app.post('/api/auth/login', async (request, reply) => {
|
||||
try {
|
||||
const data = loginSchema.parse(request.body);
|
||||
|
||||
const result = await authService.login(data.email, data.password);
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
user: result.user,
|
||||
access_token: result.tokens.accessToken,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'INVALID_CREDENTIALS') {
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: error.errors[0]?.message || 'Invalid data provided',
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Login error:', error);
|
||||
reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Login failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', async (_request, reply) => {
|
||||
reply.status(501).send({ error: 'not implemented' });
|
||||
// POST /api/auth/logout
|
||||
app.post('/api/auth/logout', async (request, reply) => {
|
||||
// Clear refresh token cookie
|
||||
reply.clearCookie('luna_refresh_token', { path: '/' });
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
message: 'Successfully logged out',
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/auth/me (protected)
|
||||
app.get('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => {
|
||||
try {
|
||||
const user = await authService.getProfile(request.user!.id);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get profile error:', error);
|
||||
reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to get profile',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/me (protected)
|
||||
app.put('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => {
|
||||
try {
|
||||
const data = updateProfileSchema.parse(request.body);
|
||||
|
||||
const user = await authService.updateProfile(request.user!.id, data);
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
user,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_NOT_FOUND') {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === 'NO_UPDATES_PROVIDED') {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'NO_UPDATES_PROVIDED',
|
||||
message: 'No updates provided',
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: error.errors[0]?.message || 'Invalid data provided',
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Update profile error:', error);
|
||||
reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to update profile',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/password (protected)
|
||||
app.put('/api/auth/me/password', { preHandler: authMiddleware }, async (request, reply) => {
|
||||
try {
|
||||
const data = changePasswordSchema.parse(request.body);
|
||||
|
||||
await authService.changePassword(request.user!.id, data.current_password, data.new_password);
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
message: 'Password changed successfully',
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_NOT_FOUND') {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === 'INVALID_CURRENT_PASSWORD') {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'INVALID_CURRENT_PASSWORD',
|
||||
message: 'Current password is incorrect',
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: error.errors[0]?.message || 'Invalid data provided',
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Change password error:', error);
|
||||
reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to change password',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh (for refreshing access tokens)
|
||||
app.post('/api/auth/refresh', async (request, reply) => {
|
||||
try {
|
||||
const refreshToken = request.cookies.luna_refresh_token;
|
||||
|
||||
if (!refreshToken) {
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
error: 'MISSING_REFRESH_TOKEN',
|
||||
message: 'Refresh token required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
|
||||
// Set new refresh token as httpOnly cookie
|
||||
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
user: result.user,
|
||||
access_token: result.tokens.accessToken,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'INVALID_REFRESH_TOKEN' || error.message === 'USER_NOT_FOUND') {
|
||||
// Clear invalid refresh token cookie
|
||||
reply.clearCookie('luna_refresh_token', { path: '/' });
|
||||
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
error: 'INVALID_REFRESH_TOKEN',
|
||||
message: 'Invalid or expired refresh token',
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Refresh token error:', error);
|
||||
reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to refresh token',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
178
backend/src/routes/households.ts
Normal file
178
backend/src/routes/households.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { householdService } from '../services/household.service.js';
|
||||
|
||||
export async function householdRoutes(app: FastifyInstance) {
|
||||
// Create a new household
|
||||
app.post('/api/households', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const { name } = request.body as { name: string };
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Household name is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const household = await householdService.createHousehold(request.user!.id, name.trim());
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: household
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: 'USER_ALREADY_IN_HOUSEHOLD',
|
||||
message: 'You are already a member of a household'
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to create household'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get my household
|
||||
app.get('/api/households/mine', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
try {
|
||||
const household = await householdService.getMyHousehold(request.user!.id);
|
||||
|
||||
if (!household) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'NO_HOUSEHOLD',
|
||||
message: 'You are not a member of any household'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: household
|
||||
});
|
||||
} catch (error: any) {
|
||||
app.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to get household'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Join a household with invite code
|
||||
app.post('/api/households/join', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const { inviteCode } = request.body as { inviteCode: string };
|
||||
|
||||
if (!inviteCode || inviteCode.trim() === '') {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Invite code is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const household = await householdService.joinHousehold(request.user!.id, inviteCode.trim().toUpperCase());
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: household
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: 'USER_ALREADY_IN_HOUSEHOLD',
|
||||
message: 'You are already a member of a household'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === 'INVALID_INVITE_CODE') {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'INVALID_INVITE_CODE',
|
||||
message: 'Invalid invite code'
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to join household'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Regenerate invite code (owner only)
|
||||
app.post('/api/households/:id/invite', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
try {
|
||||
const newInviteCode = await householdService.regenerateInviteCode(request.user!.id, id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { invite_code: newInviteCode }
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'ONLY_OWNER_CAN_REGENERATE_INVITE') {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: 'FORBIDDEN',
|
||||
message: 'Only household owners can regenerate invite codes'
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to regenerate invite code'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Leave household
|
||||
app.delete('/api/households/:id/leave', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
try {
|
||||
await householdService.leaveHousehold(request.user!.id, id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: 'Successfully left household'
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message === 'NOT_HOUSEHOLD_MEMBER') {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'NOT_HOUSEHOLD_MEMBER',
|
||||
message: 'You are not a member of this household'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === 'OWNER_CANNOT_LEAVE_WITH_MEMBERS') {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: 'OWNER_CANNOT_LEAVE_WITH_MEMBERS',
|
||||
message: 'Household owners cannot leave while other members remain'
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to leave household'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,44 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { optionalAuthMiddleware } from '../middleware/auth.js';
|
||||
import * as svc from '../services/note.service.js';
|
||||
|
||||
export async function noteRoutes(app: FastifyInstance) {
|
||||
app.get('/api/recipes/:id/notes', async (request) => {
|
||||
app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return svc.listNotes(id);
|
||||
const userId = request.user?.id;
|
||||
|
||||
return svc.listNotes(id, userId);
|
||||
});
|
||||
|
||||
app.post('/api/recipes/:id/notes', async (request, reply) => {
|
||||
app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { content } = request.body as { content: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||
const note = svc.createNote(id, content);
|
||||
|
||||
const note = svc.createNote(id, content, userId);
|
||||
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send(note);
|
||||
});
|
||||
|
||||
app.put('/api/notes/:id', async (request, reply) => {
|
||||
app.put('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { content } = request.body as { content: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||
const note = svc.updateNote(id, content);
|
||||
|
||||
const note = svc.updateNote(id, content, userId);
|
||||
if (!note) return reply.status(404).send({ error: 'Not found' });
|
||||
return note;
|
||||
});
|
||||
|
||||
app.delete('/api/notes/:id', async (request, reply) => {
|
||||
app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = svc.deleteNote(id);
|
||||
const userId = request.user?.id;
|
||||
|
||||
const ok = svc.deleteNote(id, userId);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { optionalAuthMiddleware, authMiddleware } from '../middleware/auth.js';
|
||||
import * as svc from '../services/recipe.service.js';
|
||||
|
||||
export async function recipeRoutes(app: FastifyInstance) {
|
||||
@@ -15,8 +16,10 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
return { data: results, total: results.length };
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (request) => {
|
||||
app.get('/api/recipes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const query = request.query as any;
|
||||
const userId = request.user?.id;
|
||||
|
||||
return svc.listRecipes({
|
||||
page: query.page ? Number(query.page) : undefined,
|
||||
limit: query.limit ? Number(query.limit) : undefined,
|
||||
@@ -25,6 +28,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined,
|
||||
difficulty: query.difficulty,
|
||||
maxTime: query.maxTime ? Number(query.maxTime) : undefined,
|
||||
userId: userId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,9 +60,11 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.patch('/api/recipes/:id/favorite', async (request, reply) => {
|
||||
app.patch('/api/recipes/:id/favorite', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const result = svc.toggleFavorite(id);
|
||||
const userId = request.user?.id;
|
||||
|
||||
const result = svc.toggleFavorite(id, userId);
|
||||
if (!result) return reply.status(404).send({ error: 'Not found' });
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -1,40 +1,92 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { optionalAuthMiddleware } from '../middleware/auth.js';
|
||||
import * as svc from '../services/shopping.service.js';
|
||||
|
||||
export async function shoppingRoutes(app: FastifyInstance) {
|
||||
app.get('/api/shopping', async () => {
|
||||
return svc.listItems();
|
||||
// List shopping items with optional authentication
|
||||
app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
return svc.listItems(userId, shoppingScope);
|
||||
});
|
||||
|
||||
app.post('/api/shopping/from-recipe/:id', async (request, reply) => {
|
||||
// Add items from recipe with optional authentication
|
||||
app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const items = svc.addFromRecipe(id);
|
||||
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send({ added: items.length });
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
try {
|
||||
const items = svc.addFromRecipe(id, userId, shoppingScope);
|
||||
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send({ added: items.length });
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
|
||||
return reply.status(400).send({ error: 'You are not a member of any household' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shopping', async (request, reply) => {
|
||||
// Add single item with optional authentication
|
||||
app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
if (!name) return reply.status(400).send({ error: 'name required' });
|
||||
const item = svc.addItem(name, amount, unit);
|
||||
return reply.status(201).send(item);
|
||||
|
||||
try {
|
||||
const item = svc.addItem(name, amount, unit, userId, shoppingScope);
|
||||
return reply.status(201).send(item);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
|
||||
return reply.status(400).send({ error: 'You are not a member of any household' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/shopping/:id/check', async (request, reply) => {
|
||||
// Toggle check status with optional authentication
|
||||
app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = svc.toggleCheck(id);
|
||||
const userId = request.user?.id;
|
||||
|
||||
const item = svc.toggleCheck(id, userId);
|
||||
if (!item) return reply.status(404).send({ error: 'Not found' });
|
||||
return item;
|
||||
});
|
||||
|
||||
app.delete('/api/shopping/checked', async () => {
|
||||
const count = svc.deleteChecked();
|
||||
// Delete all items with optional authentication
|
||||
app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
const count = svc.deleteAll(userId, shoppingScope);
|
||||
return { ok: true, deleted: count };
|
||||
});
|
||||
|
||||
app.delete('/api/shopping/:id', async (request, reply) => {
|
||||
// Delete checked items with optional authentication
|
||||
app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
const count = svc.deleteChecked(userId, shoppingScope);
|
||||
return { ok: true, deleted: count };
|
||||
});
|
||||
|
||||
// Delete single item with optional authentication
|
||||
app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = svc.deleteItem(id);
|
||||
const userId = request.user?.id;
|
||||
|
||||
const ok = svc.deleteItem(id, userId);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
200
backend/src/services/auth.service.ts
Normal file
200
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { ulid } from 'ulid';
|
||||
import { getDb } from '../db/connection.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key-change-in-production';
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
const ACCESS_TOKEN_EXPIRES_IN = '15m';
|
||||
const REFRESH_TOKEN_EXPIRES_IN = '7d';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
user: User;
|
||||
tokens: AuthTokens;
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
private db = getDb();
|
||||
|
||||
async register(email: string, password: string, displayName: string): Promise<AuthResult> {
|
||||
// Check if user already exists
|
||||
const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existingUser) {
|
||||
throw new Error('EMAIL_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
// Create user
|
||||
const userId = ulid();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, email, passwordHash, displayName, now, now);
|
||||
|
||||
// Get created user
|
||||
const user = this.db.prepare(`
|
||||
SELECT id, email, display_name, avatar_url, created_at, updated_at
|
||||
FROM users WHERE id = ?
|
||||
`).get(userId) as User;
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(user);
|
||||
|
||||
return { user, tokens };
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthResult> {
|
||||
// Get user by email
|
||||
const userWithPassword = this.db.prepare(`
|
||||
SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at
|
||||
FROM users WHERE email = ?
|
||||
`).get(email) as any;
|
||||
|
||||
if (!userWithPassword) {
|
||||
throw new Error('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
// Check password
|
||||
const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash);
|
||||
if (!passwordValid) {
|
||||
throw new Error('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
// Remove password from user object
|
||||
const { password_hash, ...user } = userWithPassword;
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(user);
|
||||
|
||||
return { user, tokens };
|
||||
}
|
||||
|
||||
async getProfile(userId: string): Promise<User | null> {
|
||||
const user = this.db.prepare(`
|
||||
SELECT id, email, display_name, avatar_url, created_at, updated_at
|
||||
FROM users WHERE id = ?
|
||||
`).get(userId) as User | undefined;
|
||||
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise<User> {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.display_name !== undefined) {
|
||||
updates.push('display_name = ?');
|
||||
values.push(data.display_name);
|
||||
}
|
||||
|
||||
if (data.avatar_url !== undefined) {
|
||||
updates.push('avatar_url = ?');
|
||||
values.push(data.avatar_url);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('NO_UPDATES_PROVIDED');
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?');
|
||||
values.push(new Date().toISOString());
|
||||
values.push(userId);
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE users SET ${updates.join(', ')} WHERE id = ?
|
||||
`).run(...values);
|
||||
|
||||
const user = await this.getProfile(userId);
|
||||
if (!user) {
|
||||
throw new Error('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
||||
// Get current password hash
|
||||
const userWithPassword = this.db.prepare(`
|
||||
SELECT password_hash FROM users WHERE id = ?
|
||||
`).get(userId) as any;
|
||||
|
||||
if (!userWithPassword) {
|
||||
throw new Error('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const currentPasswordValid = await bcrypt.compare(currentPassword, userWithPassword.password_hash);
|
||||
if (!currentPasswordValid) {
|
||||
throw new Error('INVALID_CURRENT_PASSWORD');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
|
||||
// Update password
|
||||
this.db.prepare(`
|
||||
UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?
|
||||
`).run(newPasswordHash, new Date().toISOString(), userId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any;
|
||||
const user = await this.getProfile(decoded.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const tokens = this.generateTokens(user);
|
||||
return { user, tokens };
|
||||
} catch (error) {
|
||||
throw new Error('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): any {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
throw new Error('INVALID_ACCESS_TOKEN');
|
||||
}
|
||||
}
|
||||
|
||||
private generateTokens(user: User): AuthTokens {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN });
|
||||
const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
218
backend/src/services/household.service.ts
Normal file
218
backend/src/services/household.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { ulid } from 'ulid';
|
||||
import { getDb } from '../db/connection.js';
|
||||
|
||||
export interface Household {
|
||||
id: string;
|
||||
name: string;
|
||||
invite_code: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface HouseholdMember {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
role: 'owner' | 'member';
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface HouseholdWithMembers extends Household {
|
||||
members: HouseholdMember[];
|
||||
}
|
||||
|
||||
class HouseholdService {
|
||||
private db = getDb();
|
||||
|
||||
// Generate a random 8-character invite code
|
||||
private generateInviteCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ensure invite code is unique
|
||||
private generateUniqueInviteCode(): string {
|
||||
let code: string;
|
||||
let attempts = 0;
|
||||
do {
|
||||
code = this.generateInviteCode();
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
throw new Error('Could not generate unique invite code');
|
||||
}
|
||||
} while (this.db.prepare('SELECT id FROM households WHERE invite_code = ?').get(code));
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
async createHousehold(userId: string, name: string): Promise<HouseholdWithMembers> {
|
||||
// Check if user is already in a household
|
||||
const existingMembership = this.db.prepare(`
|
||||
SELECT household_id FROM household_members WHERE user_id = ?
|
||||
`).get(userId);
|
||||
|
||||
if (existingMembership) {
|
||||
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
|
||||
}
|
||||
|
||||
const householdId = ulid();
|
||||
const inviteCode = this.generateUniqueInviteCode();
|
||||
|
||||
// Create household
|
||||
this.db.prepare(`
|
||||
INSERT INTO households (id, name, invite_code)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(householdId, name, inviteCode);
|
||||
|
||||
// Add user as owner
|
||||
this.db.prepare(`
|
||||
INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES (?, ?, 'owner')
|
||||
`).run(householdId, userId);
|
||||
|
||||
// Return household with members
|
||||
const household = await this.getMyHousehold(userId);
|
||||
if (!household) {
|
||||
throw new Error('Failed to create household');
|
||||
}
|
||||
|
||||
return household;
|
||||
}
|
||||
|
||||
async joinHousehold(userId: string, inviteCode: string): Promise<HouseholdWithMembers> {
|
||||
// Check if user is already in a household
|
||||
const existingMembership = this.db.prepare(`
|
||||
SELECT household_id FROM household_members WHERE user_id = ?
|
||||
`).get(userId);
|
||||
|
||||
if (existingMembership) {
|
||||
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
|
||||
}
|
||||
|
||||
// Find household by invite code
|
||||
const household = this.db.prepare(`
|
||||
SELECT id FROM households WHERE invite_code = ?
|
||||
`).get(inviteCode) as { id: string } | undefined;
|
||||
|
||||
if (!household) {
|
||||
throw new Error('INVALID_INVITE_CODE');
|
||||
}
|
||||
|
||||
// Add user as member
|
||||
this.db.prepare(`
|
||||
INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES (?, ?, 'member')
|
||||
`).run(household.id, userId);
|
||||
|
||||
// Return household with members
|
||||
const result = await this.getMyHousehold(userId);
|
||||
if (!result) {
|
||||
throw new Error('Failed to join household');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getMyHousehold(userId: string): Promise<HouseholdWithMembers | null> {
|
||||
// Get user's household
|
||||
const householdMembership = this.db.prepare(`
|
||||
SELECT h.*, hm.role
|
||||
FROM households h
|
||||
JOIN household_members hm ON h.id = hm.household_id
|
||||
WHERE hm.user_id = ?
|
||||
`).get(userId) as (Household & { role: 'owner' | 'member' }) | undefined;
|
||||
|
||||
if (!householdMembership) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all members of the household
|
||||
const members = this.db.prepare(`
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.email,
|
||||
u.display_name,
|
||||
u.avatar_url,
|
||||
hm.role,
|
||||
hm.joined_at
|
||||
FROM household_members hm
|
||||
JOIN users u ON hm.user_id = u.id
|
||||
WHERE hm.household_id = ?
|
||||
ORDER BY hm.role DESC, hm.joined_at ASC
|
||||
`).all(householdMembership.id) as HouseholdMember[];
|
||||
|
||||
const { role, ...household } = householdMembership;
|
||||
|
||||
return {
|
||||
...household,
|
||||
members
|
||||
};
|
||||
}
|
||||
|
||||
async leaveHousehold(userId: string, householdId: string): Promise<void> {
|
||||
// Check if user is member of this household
|
||||
const membership = this.db.prepare(`
|
||||
SELECT role FROM household_members
|
||||
WHERE user_id = ? AND household_id = ?
|
||||
`).get(userId, householdId) as { role: string } | undefined;
|
||||
|
||||
if (!membership) {
|
||||
throw new Error('NOT_HOUSEHOLD_MEMBER');
|
||||
}
|
||||
|
||||
// If user is owner, check if there are other members
|
||||
if (membership.role === 'owner') {
|
||||
const memberCount = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM household_members WHERE household_id = ?
|
||||
`).get(householdId) as { count: number };
|
||||
|
||||
if (memberCount.count > 1) {
|
||||
throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS');
|
||||
}
|
||||
|
||||
// If owner is the only member, delete the entire household
|
||||
this.db.prepare('DELETE FROM households WHERE id = ?').run(householdId);
|
||||
} else {
|
||||
// Remove member from household
|
||||
this.db.prepare(`
|
||||
DELETE FROM household_members
|
||||
WHERE user_id = ? AND household_id = ?
|
||||
`).run(userId, householdId);
|
||||
}
|
||||
}
|
||||
|
||||
async regenerateInviteCode(userId: string, householdId: string): Promise<string> {
|
||||
// Check if user is owner of this household
|
||||
const membership = this.db.prepare(`
|
||||
SELECT role FROM household_members
|
||||
WHERE user_id = ? AND household_id = ?
|
||||
`).get(userId, householdId) as { role: string } | undefined;
|
||||
|
||||
if (!membership || membership.role !== 'owner') {
|
||||
throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE');
|
||||
}
|
||||
|
||||
const newInviteCode = this.generateUniqueInviteCode();
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE households SET invite_code = ? WHERE id = ?
|
||||
`).run(newInviteCode, householdId);
|
||||
|
||||
return newInviteCode;
|
||||
}
|
||||
|
||||
async getHouseholdByInviteCode(inviteCode: string): Promise<Household | null> {
|
||||
const household = this.db.prepare(`
|
||||
SELECT id, name, invite_code, created_at
|
||||
FROM households WHERE invite_code = ?
|
||||
`).get(inviteCode) as Household | undefined;
|
||||
|
||||
return household || null;
|
||||
}
|
||||
}
|
||||
|
||||
export const householdService = new HouseholdService();
|
||||
@@ -1,26 +1,64 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
export function listNotes(recipeId: string) {
|
||||
return getDb().prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipeId);
|
||||
export function listNotes(recipeId: string, userId?: string) {
|
||||
const db = getDb();
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: return all notes without user filtering
|
||||
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id IS NULL ORDER BY created_at DESC').all(recipeId);
|
||||
}
|
||||
|
||||
// Return only user's notes
|
||||
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id = ? ORDER BY created_at DESC').all(recipeId, userId);
|
||||
}
|
||||
|
||||
export function createNote(recipeId: string, content: string) {
|
||||
export function createNote(recipeId: string, content: string, userId?: string) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
||||
if (!recipe) return null;
|
||||
|
||||
const id = ulid();
|
||||
db.prepare('INSERT INTO notes (id, recipe_id, content) VALUES (?, ?, ?)').run(id, recipeId, content);
|
||||
db.prepare('INSERT INTO notes (id, recipe_id, content, user_id) VALUES (?, ?, ?, ?)').run(id, recipeId, content, userId || null);
|
||||
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function updateNote(id: string, content: string) {
|
||||
export function updateNote(id: string, content: string, userId?: string) {
|
||||
const db = getDb();
|
||||
const result = db.prepare('UPDATE notes SET content = ? WHERE id = ?').run(content, id);
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: update notes without user filtering
|
||||
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id IS NULL';
|
||||
params = [content, id];
|
||||
} else {
|
||||
// Update only if note belongs to user
|
||||
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id = ?';
|
||||
params = [content, id, userId];
|
||||
}
|
||||
|
||||
const result = db.prepare(query).run(...params);
|
||||
if (result.changes === 0) return null;
|
||||
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function deleteNote(id: string): boolean {
|
||||
return getDb().prepare('DELETE FROM notes WHERE id = ?').run(id).changes > 0;
|
||||
export function deleteNote(id: string, userId?: string): boolean {
|
||||
const db = getDb();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: delete notes without user filtering
|
||||
query = 'DELETE FROM notes WHERE id = ? AND user_id IS NULL';
|
||||
params = [id];
|
||||
} else {
|
||||
// Delete only if note belongs to user
|
||||
query = 'DELETE FROM notes WHERE id = ? AND user_id = ?';
|
||||
params = [id, userId];
|
||||
}
|
||||
|
||||
return db.prepare(query).run(...params).changes > 0;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function mapTimeFields(row: any) {
|
||||
|
||||
export function listRecipes(opts: {
|
||||
page?: number; limit?: number; category_id?: string; category_slug?: string;
|
||||
favorite?: boolean; difficulty?: string; maxTime?: number;
|
||||
favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string;
|
||||
}) {
|
||||
const db = getDb();
|
||||
const page = opts.page || 1;
|
||||
@@ -74,15 +74,30 @@ export function listRecipes(opts: {
|
||||
|
||||
if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); }
|
||||
if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); }
|
||||
if (opts.favorite !== undefined) { conditions.push('r.is_favorite = ?'); params.push(opts.favorite ? 1 : 0); }
|
||||
if (opts.favorite !== undefined && opts.userId) {
|
||||
conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL'));
|
||||
}
|
||||
if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); }
|
||||
if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); }
|
||||
|
||||
let joins = 'LEFT JOIN categories c ON r.category_id = c.id';
|
||||
if (opts.userId) {
|
||||
joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?';
|
||||
params.unshift(opts.userId);
|
||||
}
|
||||
|
||||
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where}`).get(...params) as any;
|
||||
|
||||
// Adjust parameter positions based on whether userId is included
|
||||
const countParams = opts.userId ? [opts.userId, ...params.slice(1)] : params;
|
||||
const dataParams = opts.userId ? [opts.userId, ...params.slice(1), limit, offset] : [...params, limit, offset];
|
||||
|
||||
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`).get(...countParams) as any;
|
||||
const rows = db.prepare(
|
||||
`SELECT r.*, c.name as category_name, c.slug as category_slug FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, limit, offset);
|
||||
`SELECT r.*, c.name as category_name, c.slug as category_slug,
|
||||
${opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite'}
|
||||
FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...dataParams);
|
||||
|
||||
const data = rows.map(mapTimeFields);
|
||||
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
|
||||
@@ -205,13 +220,34 @@ export function deleteRecipe(id: string): boolean {
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function toggleFavorite(id: string) {
|
||||
export function toggleFavorite(id: string, userId?: string) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
|
||||
if (!recipe) return null;
|
||||
const newVal = recipe.is_favorite ? 0 : 1;
|
||||
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
|
||||
return { id, is_favorite: newVal };
|
||||
|
||||
// If no user authentication, fallback to old is_favorite column
|
||||
if (!userId) {
|
||||
const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
|
||||
const newVal = recipeWithFavorite.is_favorite ? 0 : 1;
|
||||
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
|
||||
return { id, is_favorite: newVal };
|
||||
}
|
||||
|
||||
// Check if recipe is already favorited by user
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?'
|
||||
).get(userId, id) as any;
|
||||
|
||||
if (existing) {
|
||||
// Remove from favorites
|
||||
db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id);
|
||||
return { id, is_favorite: false };
|
||||
} else {
|
||||
// Add to favorites
|
||||
const favoriteId = ulid();
|
||||
db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id);
|
||||
return { id, is_favorite: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function getRandomRecipe() {
|
||||
|
||||
@@ -1,14 +1,48 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
export function listItems() {
|
||||
export type ShoppingScope = 'personal' | 'household';
|
||||
|
||||
export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
|
||||
const db = getDb();
|
||||
const items = db.prepare(`
|
||||
SELECT si.*, r.title as recipe_title
|
||||
FROM shopping_items si
|
||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||
ORDER BY si.checked, si.created_at DESC
|
||||
`).all() as any[];
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: no user authentication, return all items
|
||||
query = `
|
||||
SELECT si.*, r.title as recipe_title
|
||||
FROM shopping_items si
|
||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||
WHERE si.user_id IS NULL AND si.household_id IS NULL
|
||||
ORDER BY si.checked, si.created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
} else if (scope === 'household') {
|
||||
// Get household shopping list
|
||||
query = `
|
||||
SELECT si.*, r.title as recipe_title
|
||||
FROM shopping_items si
|
||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||
LEFT JOIN household_members hm ON si.household_id = hm.household_id
|
||||
WHERE hm.user_id = ? AND si.household_id IS NOT NULL
|
||||
ORDER BY si.checked, si.created_at DESC
|
||||
`;
|
||||
params = [userId];
|
||||
} else {
|
||||
// Get personal shopping list
|
||||
query = `
|
||||
SELECT si.*, r.title as recipe_title
|
||||
FROM shopping_items si
|
||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||
WHERE si.user_id = ? AND si.household_id IS NULL
|
||||
ORDER BY si.checked, si.created_at DESC
|
||||
`;
|
||||
params = [userId];
|
||||
}
|
||||
|
||||
const items = db.prepare(query).all(...params) as any[];
|
||||
|
||||
const grouped: Record<string, any> = {};
|
||||
for (const item of items) {
|
||||
@@ -21,44 +55,183 @@ export function listItems() {
|
||||
return Object.values(grouped);
|
||||
}
|
||||
|
||||
export function addFromRecipe(recipeId: string) {
|
||||
export function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
||||
if (!recipe) return null;
|
||||
|
||||
const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[];
|
||||
const insert = db.prepare('INSERT INTO shopping_items (id, name, amount, unit, recipe_id) VALUES (?, ?, ?, ?, ?)');
|
||||
|
||||
let householdId = null;
|
||||
if (userId && scope === 'household') {
|
||||
// Get user's household
|
||||
const membership = db.prepare(`
|
||||
SELECT household_id FROM household_members WHERE user_id = ?
|
||||
`).get(userId) as { household_id: string } | undefined;
|
||||
|
||||
if (!membership) {
|
||||
throw new Error('USER_NOT_IN_HOUSEHOLD');
|
||||
}
|
||||
householdId = membership.household_id;
|
||||
}
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const added: any[] = [];
|
||||
const txn = db.transaction(() => {
|
||||
for (const ing of ingredients) {
|
||||
const id = ulid();
|
||||
insert.run(id, ing.name, ing.amount, ing.unit, recipeId);
|
||||
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, checked: 0 });
|
||||
insert.run(id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId);
|
||||
added.push({
|
||||
id,
|
||||
name: ing.name,
|
||||
amount: ing.amount,
|
||||
unit: ing.unit,
|
||||
recipe_id: recipeId,
|
||||
user_id: userId || null,
|
||||
household_id: householdId,
|
||||
checked: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
txn();
|
||||
return added;
|
||||
}
|
||||
|
||||
export function addItem(name: string, amount?: number, unit?: string) {
|
||||
export function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') {
|
||||
const db = getDb();
|
||||
|
||||
let householdId = null;
|
||||
if (userId && scope === 'household') {
|
||||
// Get user's household
|
||||
const membership = db.prepare(`
|
||||
SELECT household_id FROM household_members WHERE user_id = ?
|
||||
`).get(userId) as { household_id: string } | undefined;
|
||||
|
||||
if (!membership) {
|
||||
throw new Error('USER_NOT_IN_HOUSEHOLD');
|
||||
}
|
||||
householdId = membership.household_id;
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
db.prepare('INSERT INTO shopping_items (id, name, amount, unit) VALUES (?, ?, ?, ?)').run(id, name, amount ?? null, unit ?? null);
|
||||
db.prepare(`
|
||||
INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, amount ?? null, unit ?? null, userId || null, householdId);
|
||||
|
||||
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function toggleCheck(id: string) {
|
||||
export function toggleCheck(id: string, userId?: string) {
|
||||
const db = getDb();
|
||||
const item = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id) as any;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: no user authentication
|
||||
query = 'SELECT * FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
|
||||
params = [id];
|
||||
} else {
|
||||
// Check if item belongs to user (personal) or their household
|
||||
query = `
|
||||
SELECT si.* FROM shopping_items si
|
||||
LEFT JOIN household_members hm ON si.household_id = hm.household_id
|
||||
WHERE si.id = ? AND (si.user_id = ? OR hm.user_id = ?)
|
||||
`;
|
||||
params = [id, userId, userId];
|
||||
}
|
||||
|
||||
const item = db.prepare(query).get(...params) as any;
|
||||
if (!item) return null;
|
||||
|
||||
const newVal = item.checked ? 0 : 1;
|
||||
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
|
||||
return { ...item, checked: newVal };
|
||||
}
|
||||
|
||||
export function deleteItem(id: string): boolean {
|
||||
return getDb().prepare('DELETE FROM shopping_items WHERE id = ?').run(id).changes > 0;
|
||||
export function deleteItem(id: string, userId?: string): boolean {
|
||||
const db = getDb();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: no user authentication
|
||||
query = 'DELETE FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
|
||||
params = [id];
|
||||
} else {
|
||||
// Delete only if item belongs to user (personal) or their household
|
||||
query = `
|
||||
DELETE FROM shopping_items
|
||||
WHERE id = ? AND id IN (
|
||||
SELECT si.id FROM shopping_items si
|
||||
LEFT JOIN household_members hm ON si.household_id = hm.household_id
|
||||
WHERE si.user_id = ? OR hm.user_id = ?
|
||||
)
|
||||
`;
|
||||
params = [id, userId, userId];
|
||||
}
|
||||
|
||||
return db.prepare(query).run(...params).changes > 0;
|
||||
}
|
||||
|
||||
export function deleteChecked(): number {
|
||||
return getDb().prepare('DELETE FROM shopping_items WHERE checked = 1').run().changes;
|
||||
export function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): number {
|
||||
const db = getDb();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: no user authentication
|
||||
query = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL';
|
||||
params = [];
|
||||
} else if (scope === 'household') {
|
||||
// Delete all household items
|
||||
query = `
|
||||
DELETE FROM shopping_items
|
||||
WHERE household_id IN (
|
||||
SELECT household_id FROM household_members WHERE user_id = ?
|
||||
)
|
||||
`;
|
||||
params = [userId];
|
||||
} else {
|
||||
// Delete all personal items
|
||||
query = 'DELETE FROM shopping_items WHERE user_id = ? AND household_id IS NULL';
|
||||
params = [userId];
|
||||
}
|
||||
|
||||
return db.prepare(query).run(...params).changes;
|
||||
}
|
||||
|
||||
export function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): number {
|
||||
const db = getDb();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (!userId) {
|
||||
// Legacy: no user authentication
|
||||
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id IS NULL AND household_id IS NULL';
|
||||
params = [];
|
||||
} else if (scope === 'household') {
|
||||
// Delete checked household items
|
||||
query = `
|
||||
DELETE FROM shopping_items
|
||||
WHERE checked = 1 AND household_id IN (
|
||||
SELECT household_id FROM household_members WHERE user_id = ?
|
||||
)
|
||||
`;
|
||||
params = [userId];
|
||||
} else {
|
||||
// Delete checked personal items
|
||||
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id = ? AND household_id IS NULL';
|
||||
params = [userId];
|
||||
}
|
||||
|
||||
return db.prepare(query).run(...params).changes;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user