Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user