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:
Azriel
2026-05-10 15:00:15 +00:00
parent d755eea792
commit e633d693da
44 changed files with 3106 additions and 0 deletions

254
src/services/authService.ts Normal file
View 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);
},
};

View 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
}
},
};

View 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,
});
},
};

View 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,
};
},
};

View 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() });
},
};

View 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();
},
};