feat: implement backend API for Pantree Phase 1 MVP
- Project setup: package.json, tsconfig.json, jest.config.js, .env.example - Config: env.ts, constants.ts (ALLOWED_UNITS, bcrypt rounds, JWT, deletion windows) - DB: Knex connection, knexfile, migrations 001-006 (users, password_reset_tokens, pantry_items, recipes+recipe_ingredients, shopping_lists+items, deleted_records) - Seeds: 10 seeded recipes with full ingredient lists - Middleware: JWT authMiddleware (validates token + user existence), errorHandler - Utils: Pino logger, JWT sign helper, Zod validators for all request shapes - Services: authService (signup/signin/google/pwd-reset/soft-delete/restore), pantryService (CRUD + case-insensitive duplicate guard), recipeService (browse+filter+scale), shoppingListService (CRUD+merge logic), syncService (delta sync + tombstone cleanup), emailService (SendGrid) - Routes: /v1/auth, /v1/pantry, /v1/recipes, /v1/shopping-lists, /v1/sync - App: Express factory (createApp), server entry point - Jobs: node-cron daily hard-delete + tombstone cleanup - Tests: validators, utils, auth, pantry, recipes, shopping lists, sync
This commit is contained in:
254
src/services/authService.ts
Normal file
254
src/services/authService.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import db from '../db/connection';
|
||||
import { config } from '../config/env';
|
||||
import { signToken } from '../utils/jwt';
|
||||
import { createError } from '../middleware/errorHandler';
|
||||
import { BCRYPT_ROUNDS, ACCOUNT_DELETION_DAYS, PASSWORD_RESET_EXPIRES_HOURS } from '../config/constants';
|
||||
import { emailService } from './emailService';
|
||||
|
||||
const googleClient = new OAuth2Client(config.googleClientId);
|
||||
|
||||
function formatUser(user: Record<string, unknown>) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
profile_picture_url: user.profile_picture_url ?? null,
|
||||
deleted_at: user.deleted_at ?? null,
|
||||
created_at: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async signup(email: string, password: string, name: string) {
|
||||
const existing = await db('users')
|
||||
.whereRaw('LOWER(email) = LOWER(?)', [email])
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
throw createError('Email already registered.', 409, 'CONFLICT');
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const [user] = await db('users')
|
||||
.insert({
|
||||
email: email.toLowerCase(),
|
||||
password_hash,
|
||||
name,
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
const { token, expiresAt } = signToken(user.id);
|
||||
|
||||
return {
|
||||
user: formatUser(user),
|
||||
token,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async signin(email: string, password: string) {
|
||||
const user = await db('users')
|
||||
.whereRaw('LOWER(email) = LOWER(?)', [email])
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
|
||||
}
|
||||
|
||||
if (!user.password_hash) {
|
||||
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw createError('Invalid credentials.', 401, 'UNAUTHORIZED');
|
||||
}
|
||||
|
||||
if (user.deleted_at) {
|
||||
const err = createError('Account is pending deletion.', 403, 'ACCOUNT_PENDING_DELETION');
|
||||
(err as Record<string, unknown>).deletion_scheduled_at = user.deletion_scheduled_at;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { token, expiresAt } = signToken(user.id);
|
||||
|
||||
return {
|
||||
user: formatUser(user),
|
||||
token,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async googleAuth(idToken: string) {
|
||||
let ticket;
|
||||
try {
|
||||
ticket = await googleClient.verifyIdToken({
|
||||
idToken,
|
||||
audience: config.googleClientId,
|
||||
});
|
||||
} catch {
|
||||
throw createError('Google token verification failed.', 401, 'INVALID_GOOGLE_TOKEN');
|
||||
}
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload || !payload.email) {
|
||||
throw createError('Google token verification failed.', 401, 'INVALID_GOOGLE_TOKEN');
|
||||
}
|
||||
|
||||
const { sub: googleId, email, name = '', picture } = payload;
|
||||
|
||||
// Check for existing user by google_id or email
|
||||
let user = await db('users')
|
||||
.where({ google_id: googleId })
|
||||
.orWhereRaw('LOWER(email) = LOWER(?)', [email])
|
||||
.first();
|
||||
|
||||
if (user && user.deleted_at) {
|
||||
const err = createError('Account is pending deletion.', 403, 'ACCOUNT_PENDING_DELETION');
|
||||
(err as Record<string, unknown>).deletion_scheduled_at = user.deletion_scheduled_at;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
[user] = await db('users')
|
||||
.insert({
|
||||
email: email.toLowerCase(),
|
||||
google_id: googleId,
|
||||
name,
|
||||
profile_picture_url: picture ?? null,
|
||||
email_verified: true,
|
||||
})
|
||||
.returning('*');
|
||||
isNewUser = true;
|
||||
} else if (!user.google_id) {
|
||||
// Link google account to existing email account
|
||||
[user] = await db('users')
|
||||
.where({ id: user.id })
|
||||
.update({
|
||||
google_id: googleId,
|
||||
profile_picture_url: user.profile_picture_url ?? picture ?? null,
|
||||
email_verified: true,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
}
|
||||
|
||||
const { token, expiresAt } = signToken(user.id);
|
||||
|
||||
return {
|
||||
user: formatUser(user),
|
||||
token,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
is_new_user: isNewUser,
|
||||
};
|
||||
},
|
||||
|
||||
async requestPasswordReset(email: string) {
|
||||
const user = await db('users')
|
||||
.whereRaw('LOWER(email) = LOWER(?)', [email])
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
|
||||
// Always return success — prevents email enumeration
|
||||
if (!user) return;
|
||||
|
||||
// Invalidate any existing unused tokens
|
||||
await db('password_reset_tokens')
|
||||
.where({ user_id: user.id })
|
||||
.whereNull('used_at')
|
||||
.update({ used_at: db.fn.now() });
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
|
||||
const expires_at = new Date(
|
||||
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
await db('password_reset_tokens').insert({
|
||||
user_id: user.id,
|
||||
token_hash,
|
||||
expires_at,
|
||||
});
|
||||
|
||||
await emailService.sendPasswordReset(user.email, rawToken);
|
||||
},
|
||||
|
||||
async confirmPasswordReset(rawToken: string, newPassword: string) {
|
||||
// Find all unexpired, unused tokens and check each
|
||||
const tokens = await db('password_reset_tokens')
|
||||
.whereNull('used_at')
|
||||
.where('expires_at', '>', db.fn.now())
|
||||
.orderBy('created_at', 'desc');
|
||||
|
||||
let matchedToken = null;
|
||||
for (const t of tokens) {
|
||||
const match = await bcrypt.compare(rawToken, t.token_hash);
|
||||
if (match) {
|
||||
matchedToken = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedToken) {
|
||||
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('users')
|
||||
.where({ id: matchedToken.user_id })
|
||||
.update({ password_hash, updated_at: trx.fn.now() });
|
||||
|
||||
await trx('password_reset_tokens')
|
||||
.where({ id: matchedToken.id })
|
||||
.update({ used_at: trx.fn.now() });
|
||||
});
|
||||
},
|
||||
|
||||
async deleteAccount(userId: string) {
|
||||
const deletionScheduledAt = new Date(
|
||||
Date.now() + ACCOUNT_DELETION_DAYS * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
await db('users').where({ id: userId }).update({
|
||||
deleted_at: db.fn.now(),
|
||||
deletion_scheduled_at: deletionScheduledAt,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
},
|
||||
|
||||
async restoreAccount(userId: string) {
|
||||
const user = await db('users').where({ id: userId }).first();
|
||||
|
||||
if (!user) {
|
||||
throw createError('Account not found.', 410, 'GONE');
|
||||
}
|
||||
|
||||
if (!user.deleted_at) {
|
||||
// Not deleted — nothing to restore, just return user
|
||||
return formatUser(user);
|
||||
}
|
||||
|
||||
if (user.deletion_scheduled_at && new Date(user.deletion_scheduled_at) <= new Date()) {
|
||||
throw createError('Account restoration window has expired.', 410, 'GONE');
|
||||
}
|
||||
|
||||
const [restored] = await db('users')
|
||||
.where({ id: userId })
|
||||
.update({
|
||||
deleted_at: null,
|
||||
deletion_scheduled_at: null,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return formatUser(restored);
|
||||
},
|
||||
};
|
||||
29
src/services/emailService.ts
Normal file
29
src/services/emailService.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { config } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const emailService = {
|
||||
async sendPasswordReset(toEmail: string, rawToken: string): Promise<void> {
|
||||
const resetUrl = `${config.passwordResetUrl}?token=${rawToken}`;
|
||||
|
||||
if (config.isTest || !config.sendgridApiKey) {
|
||||
logger.info({ toEmail, resetUrl }, 'Password reset email (not sent in test/dev without key)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sgMail = await import('@sendgrid/mail');
|
||||
sgMail.default.setApiKey(config.sendgridApiKey);
|
||||
|
||||
await sgMail.default.send({
|
||||
to: toEmail,
|
||||
from: config.sendgridFromEmail,
|
||||
subject: 'Reset your Pantree password',
|
||||
text: `Click the link to reset your password: ${resetUrl}\n\nThis link expires in 1 hour.`,
|
||||
html: `<p>Click the link to reset your password:</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>This link expires in 1 hour.</p>`,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to send password reset email');
|
||||
// Do not throw — prevents email enumeration via timing
|
||||
}
|
||||
},
|
||||
};
|
||||
73
src/services/pantryService.ts
Normal file
73
src/services/pantryService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import db from '../db/connection';
|
||||
import { createError } from '../middleware/errorHandler';
|
||||
|
||||
export const pantryService = {
|
||||
async getItems(userId: string) {
|
||||
const items = await db('pantry_items')
|
||||
.where({ user_id: userId })
|
||||
.select('id', 'item_name', 'quantity', 'last_modified', 'created_at')
|
||||
.orderBy('item_name_lower', 'asc');
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
async addItem(userId: string, itemName: string, quantity: number) {
|
||||
const existing = await db('pantry_items')
|
||||
.where({ user_id: userId, item_name_lower: itemName.toLowerCase() })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
throw createError(
|
||||
`Item '${itemName}' already exists in your pantry.`,
|
||||
409,
|
||||
'DUPLICATE_ITEM'
|
||||
);
|
||||
}
|
||||
|
||||
const [item] = await db('pantry_items')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
item_name: itemName,
|
||||
item_name_lower: itemName.toLowerCase(),
|
||||
quantity,
|
||||
last_modified: db.fn.now(),
|
||||
})
|
||||
.returning(['id', 'item_name', 'quantity', 'last_modified', 'created_at']);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
async updateItem(userId: string, itemId: string, quantity: number) {
|
||||
const [item] = await db('pantry_items')
|
||||
.where({ id: itemId, user_id: userId })
|
||||
.update({
|
||||
quantity,
|
||||
last_modified: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning(['id', 'item_name', 'quantity', 'last_modified', 'created_at']);
|
||||
|
||||
if (!item) {
|
||||
throw createError('Pantry item not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
async deleteItem(userId: string, itemId: string) {
|
||||
const deleted = await db('pantry_items')
|
||||
.where({ id: itemId, user_id: userId })
|
||||
.delete();
|
||||
|
||||
if (!deleted) {
|
||||
throw createError('Pantry item not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Record tombstone for sync
|
||||
await db('deleted_records').insert({
|
||||
user_id: userId,
|
||||
table_name: 'pantry_items',
|
||||
record_id: itemId,
|
||||
});
|
||||
},
|
||||
};
|
||||
136
src/services/recipeService.ts
Normal file
136
src/services/recipeService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import db from '../db/connection';
|
||||
import { createError } from '../middleware/errorHandler';
|
||||
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../config/constants';
|
||||
|
||||
export const recipeService = {
|
||||
async getRecipes(
|
||||
userId: string,
|
||||
filter: 'all' | 'can_make' | 'can_partially_make',
|
||||
page: number,
|
||||
limit: number,
|
||||
search?: string
|
||||
) {
|
||||
const safeLimit = Math.min(limit, MAX_PAGE_LIMIT);
|
||||
const offset = (page - 1) * safeLimit;
|
||||
|
||||
// Get user's pantry item names (lowercase)
|
||||
const pantryItems = await db('pantry_items')
|
||||
.where({ user_id: userId })
|
||||
.pluck('item_name_lower');
|
||||
|
||||
const pantrySet = new Set(pantryItems);
|
||||
|
||||
// Base query
|
||||
let query = db('recipes').select('recipes.*');
|
||||
|
||||
if (search) {
|
||||
query = query.whereRaw('LOWER(recipes.name) LIKE ?', [`%${search.toLowerCase()}%`]);
|
||||
}
|
||||
|
||||
const allRecipes = await query.orderBy('recipes.name', 'asc');
|
||||
|
||||
// Get ingredients for all recipes in one query
|
||||
const recipeIds = allRecipes.map((r) => r.id);
|
||||
const allIngredients = recipeIds.length
|
||||
? await db('recipe_ingredients').whereIn('recipe_id', recipeIds)
|
||||
: [];
|
||||
|
||||
const ingredientsByRecipe = new Map<string, typeof allIngredients>();
|
||||
for (const ing of allIngredients) {
|
||||
if (!ingredientsByRecipe.has(ing.recipe_id)) {
|
||||
ingredientsByRecipe.set(ing.recipe_id, []);
|
||||
}
|
||||
ingredientsByRecipe.get(ing.recipe_id)!.push(ing);
|
||||
}
|
||||
|
||||
// Compute availability for each recipe
|
||||
const enriched = allRecipes.map((recipe) => {
|
||||
const ingredients = ingredientsByRecipe.get(recipe.id) ?? [];
|
||||
const ingredientCount = ingredients.length;
|
||||
const availableCount = ingredients.filter((i) =>
|
||||
pantrySet.has(i.item_name_lower)
|
||||
).length;
|
||||
const canMake = ingredientCount > 0 && availableCount === ingredientCount;
|
||||
const canPartiallyMake = availableCount > 0 && availableCount < ingredientCount;
|
||||
const availabilityPct =
|
||||
ingredientCount > 0
|
||||
? parseFloat(((availableCount / ingredientCount) * 100).toFixed(2))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
servings: recipe.servings,
|
||||
ingredient_count: ingredientCount,
|
||||
available_ingredient_count: availableCount,
|
||||
can_make: canMake,
|
||||
can_partially_make: canPartiallyMake,
|
||||
availability_percentage: availabilityPct,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply filter
|
||||
let filtered = enriched;
|
||||
if (filter === 'can_make') {
|
||||
filtered = enriched.filter((r) => r.can_make);
|
||||
} else if (filter === 'can_partially_make') {
|
||||
filtered = enriched.filter((r) => r.can_partially_make);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.ceil(total / safeLimit);
|
||||
const paginated = filtered.slice(offset, offset + safeLimit);
|
||||
|
||||
return {
|
||||
recipes: paginated,
|
||||
pagination: {
|
||||
page,
|
||||
limit: safeLimit,
|
||||
total,
|
||||
total_pages: totalPages,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async getRecipeById(recipeId: string, userId: string, scaleFactor: number) {
|
||||
const recipe = await db('recipes').where({ id: recipeId }).first();
|
||||
|
||||
if (!recipe) {
|
||||
throw createError('Recipe not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
const ingredients = await db('recipe_ingredients').where({ recipe_id: recipeId });
|
||||
|
||||
const pantryItems = await db('pantry_items')
|
||||
.where({ user_id: userId })
|
||||
.pluck('item_name_lower');
|
||||
|
||||
const pantrySet = new Set(pantryItems);
|
||||
|
||||
const scaledIngredients = ingredients.map((ing) => ({
|
||||
id: ing.id,
|
||||
item_name: ing.item_name,
|
||||
quantity: parseFloat((parseFloat(ing.quantity) * scaleFactor).toFixed(4)),
|
||||
original_quantity: parseFloat(ing.quantity),
|
||||
unit: ing.unit,
|
||||
in_pantry: pantrySet.has(ing.item_name_lower),
|
||||
}));
|
||||
|
||||
const availableCount = scaledIngredients.filter((i) => i.in_pantry).length;
|
||||
const canMake =
|
||||
scaledIngredients.length > 0 && availableCount === scaledIngredients.length;
|
||||
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
servings: recipe.servings,
|
||||
scaled_servings: recipe.servings * scaleFactor,
|
||||
scale_factor: scaleFactor,
|
||||
instructions: recipe.instructions,
|
||||
ingredients: scaledIngredients,
|
||||
can_make: canMake,
|
||||
available_ingredient_count: availableCount,
|
||||
ingredient_count: scaledIngredients.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
267
src/services/shoppingListService.ts
Normal file
267
src/services/shoppingListService.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import db from '../db/connection';
|
||||
import { createError } from '../middleware/errorHandler';
|
||||
|
||||
async function getListForUser(listId: string, userId: string) {
|
||||
const list = await db('shopping_lists')
|
||||
.where({ id: listId, user_id: userId })
|
||||
.first();
|
||||
|
||||
if (!list) {
|
||||
throw createError('Shopping list not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export const shoppingListService = {
|
||||
async getLists(userId: string) {
|
||||
const lists = await db('shopping_lists')
|
||||
.where({ user_id: userId })
|
||||
.select('id', 'list_name', 'last_modified', 'created_at')
|
||||
.orderBy('created_at', 'desc');
|
||||
|
||||
// Get item counts in one query
|
||||
const listIds = lists.map((l) => l.id);
|
||||
const counts = listIds.length
|
||||
? await db('shopping_list_items')
|
||||
.whereIn('shopping_list_id', listIds)
|
||||
.select('shopping_list_id')
|
||||
.count('id as item_count')
|
||||
.sum(db.raw('CASE WHEN checked_off THEN 1 ELSE 0 END as checked_count'))
|
||||
.groupBy('shopping_list_id')
|
||||
: [];
|
||||
|
||||
const countMap = new Map(
|
||||
counts.map((c) => [
|
||||
c.shopping_list_id,
|
||||
{ item_count: parseInt(String(c.item_count), 10), checked_count: parseInt(String(c.checked_count), 10) },
|
||||
])
|
||||
);
|
||||
|
||||
return lists.map((l) => ({
|
||||
...l,
|
||||
item_count: countMap.get(l.id)?.item_count ?? 0,
|
||||
checked_count: countMap.get(l.id)?.checked_count ?? 0,
|
||||
}));
|
||||
},
|
||||
|
||||
async createList(userId: string, listName: string) {
|
||||
const [list] = await db('shopping_lists')
|
||||
.insert({ user_id: userId, list_name: listName })
|
||||
.returning(['id', 'list_name', 'last_modified', 'created_at']);
|
||||
|
||||
return { ...list, item_count: 0, checked_count: 0 };
|
||||
},
|
||||
|
||||
async getListById(listId: string, userId: string) {
|
||||
const list = await getListForUser(listId, userId);
|
||||
|
||||
const items = await db('shopping_list_items')
|
||||
.where({ shopping_list_id: listId })
|
||||
.select('id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified')
|
||||
.orderBy('item_name_lower', 'asc');
|
||||
|
||||
return {
|
||||
id: list.id,
|
||||
list_name: list.list_name,
|
||||
last_modified: list.last_modified,
|
||||
created_at: list.created_at,
|
||||
items,
|
||||
};
|
||||
},
|
||||
|
||||
async deleteList(listId: string, userId: string) {
|
||||
const deleted = await db('shopping_lists')
|
||||
.where({ id: listId, user_id: userId })
|
||||
.delete();
|
||||
|
||||
if (!deleted) {
|
||||
throw createError('Shopping list not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
await db('deleted_records').insert({
|
||||
user_id: userId,
|
||||
table_name: 'shopping_lists',
|
||||
record_id: listId,
|
||||
});
|
||||
},
|
||||
|
||||
async addItem(
|
||||
listId: string,
|
||||
userId: string,
|
||||
itemName: string,
|
||||
quantity: number,
|
||||
unit: string
|
||||
) {
|
||||
await getListForUser(listId, userId);
|
||||
|
||||
const existing = await db('shopping_list_items')
|
||||
.where({
|
||||
shopping_list_id: listId,
|
||||
item_name_lower: itemName.toLowerCase(),
|
||||
unit,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
const previousQuantity = parseFloat(existing.quantity);
|
||||
const newQuantity = previousQuantity + quantity;
|
||||
|
||||
const [updated] = await db('shopping_list_items')
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
quantity: newQuantity,
|
||||
last_modified: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
|
||||
|
||||
// Update list last_modified
|
||||
await db('shopping_lists')
|
||||
.where({ id: listId })
|
||||
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||
|
||||
return { item: updated, merged: true, previous_quantity: previousQuantity };
|
||||
}
|
||||
|
||||
const [item] = await db('shopping_list_items')
|
||||
.insert({
|
||||
shopping_list_id: listId,
|
||||
item_name: itemName,
|
||||
item_name_lower: itemName.toLowerCase(),
|
||||
quantity,
|
||||
unit,
|
||||
last_modified: db.fn.now(),
|
||||
})
|
||||
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
|
||||
|
||||
await db('shopping_lists')
|
||||
.where({ id: listId })
|
||||
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||
|
||||
return { item, merged: false };
|
||||
},
|
||||
|
||||
async addRecipesToList(
|
||||
listId: string,
|
||||
userId: string,
|
||||
recipeIds: string[],
|
||||
scaleFactor: number
|
||||
) {
|
||||
await getListForUser(listId, userId);
|
||||
|
||||
// Validate all recipes exist
|
||||
const recipes = await db('recipes').whereIn('id', recipeIds).select('id');
|
||||
if (recipes.length !== recipeIds.length) {
|
||||
throw createError('One or more recipes not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
const ingredients = await db('recipe_ingredients').whereIn('recipe_id', recipeIds);
|
||||
|
||||
let itemsMerged = 0;
|
||||
let itemsCreated = 0;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (const ing of ingredients) {
|
||||
const scaledQty = parseFloat(ing.quantity) * scaleFactor;
|
||||
|
||||
const existing = await trx('shopping_list_items')
|
||||
.where({
|
||||
shopping_list_id: listId,
|
||||
item_name_lower: ing.item_name_lower,
|
||||
unit: ing.unit,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await trx('shopping_list_items')
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
quantity: parseFloat(existing.quantity) + scaledQty,
|
||||
last_modified: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
itemsMerged++;
|
||||
} else {
|
||||
await trx('shopping_list_items').insert({
|
||||
shopping_list_id: listId,
|
||||
item_name: ing.item_name,
|
||||
item_name_lower: ing.item_name_lower,
|
||||
quantity: scaledQty,
|
||||
unit: ing.unit,
|
||||
last_modified: trx.fn.now(),
|
||||
});
|
||||
itemsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
await trx('shopping_lists')
|
||||
.where({ id: listId })
|
||||
.update({ last_modified: trx.fn.now(), updated_at: trx.fn.now() });
|
||||
});
|
||||
|
||||
const updatedList = await this.getListById(listId, userId);
|
||||
|
||||
return {
|
||||
shopping_list: updatedList,
|
||||
recipes_added: recipeIds.length,
|
||||
items_merged: itemsMerged,
|
||||
items_created: itemsCreated,
|
||||
};
|
||||
},
|
||||
|
||||
async updateItem(
|
||||
listId: string,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
updates: { quantity?: number; unit?: string; checked_off?: boolean }
|
||||
) {
|
||||
await getListForUser(listId, userId);
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
last_modified: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
};
|
||||
|
||||
if (updates.quantity !== undefined) updateData.quantity = updates.quantity;
|
||||
if (updates.unit !== undefined) updateData.unit = updates.unit;
|
||||
if (updates.checked_off !== undefined) updateData.checked_off = updates.checked_off;
|
||||
|
||||
const [item] = await db('shopping_list_items')
|
||||
.where({ id: itemId, shopping_list_id: listId })
|
||||
.update(updateData)
|
||||
.returning(['id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified']);
|
||||
|
||||
if (!item) {
|
||||
throw createError('Shopping list item not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
await db('shopping_lists')
|
||||
.where({ id: listId })
|
||||
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
async deleteItem(listId: string, itemId: string, userId: string) {
|
||||
await getListForUser(listId, userId);
|
||||
|
||||
const deleted = await db('shopping_list_items')
|
||||
.where({ id: itemId, shopping_list_id: listId })
|
||||
.delete();
|
||||
|
||||
if (!deleted) {
|
||||
throw createError('Shopping list item not found.', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
await db('deleted_records').insert({
|
||||
user_id: userId,
|
||||
table_name: 'shopping_list_items',
|
||||
record_id: itemId,
|
||||
});
|
||||
|
||||
await db('shopping_lists')
|
||||
.where({ id: listId })
|
||||
.update({ last_modified: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
};
|
||||
75
src/services/syncService.ts
Normal file
75
src/services/syncService.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import db from '../db/connection';
|
||||
import { TOMBSTONE_RETENTION_DAYS } from '../config/constants';
|
||||
|
||||
export const syncService = {
|
||||
async getDelta(userId: string, since: string) {
|
||||
const serverTimestamp = new Date().toISOString();
|
||||
|
||||
// Pantry: updated items
|
||||
const updatedPantry = await db('pantry_items')
|
||||
.where({ user_id: userId })
|
||||
.where('last_modified', '>', since)
|
||||
.select('id', 'item_name', 'quantity', 'last_modified');
|
||||
|
||||
// Pantry: deleted items
|
||||
const deletedPantry = await db('deleted_records')
|
||||
.where({ user_id: userId, table_name: 'pantry_items' })
|
||||
.where('deleted_at', '>', since)
|
||||
.pluck('record_id');
|
||||
|
||||
// Shopping lists: updated
|
||||
const updatedLists = await db('shopping_lists')
|
||||
.where({ user_id: userId })
|
||||
.where('last_modified', '>', since)
|
||||
.select('id', 'list_name', 'last_modified');
|
||||
|
||||
// For each updated list, get updated/deleted items
|
||||
const listsWithItems = await Promise.all(
|
||||
updatedLists.map(async (list) => {
|
||||
const updatedItems = await db('shopping_list_items')
|
||||
.where({ shopping_list_id: list.id })
|
||||
.where('last_modified', '>', since)
|
||||
.select('id', 'item_name', 'quantity', 'unit', 'checked_off', 'last_modified');
|
||||
|
||||
const deletedItems = await db('deleted_records')
|
||||
.where({ user_id: userId, table_name: 'shopping_list_items' })
|
||||
.where('deleted_at', '>', since)
|
||||
.pluck('record_id');
|
||||
|
||||
return {
|
||||
...list,
|
||||
items: {
|
||||
updated: updatedItems,
|
||||
deleted: deletedItems,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Deleted shopping lists
|
||||
const deletedLists = await db('deleted_records')
|
||||
.where({ user_id: userId, table_name: 'shopping_lists' })
|
||||
.where('deleted_at', '>', since)
|
||||
.pluck('record_id');
|
||||
|
||||
return {
|
||||
server_timestamp: serverTimestamp,
|
||||
pantry: {
|
||||
updated: updatedPantry,
|
||||
deleted: deletedPantry,
|
||||
},
|
||||
shopping_lists: {
|
||||
updated: listsWithItems,
|
||||
deleted: deletedLists,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async cleanupTombstones() {
|
||||
const cutoff = new Date(
|
||||
Date.now() - TOMBSTONE_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
await db('deleted_records').where('deleted_at', '<', cutoff).delete();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user