From e633d693da02e87ecaa7b3a30ceedb29456e36c1 Mon Sep 17 00:00:00 2001 From: Azriel Date: Sun, 10 May 2026 15:00:15 +0000 Subject: [PATCH] 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 --- .env.example | 21 ++ .gitignore | 6 + jest.config.js | 24 ++ package.json | 49 ++++ src/app.ts | 42 +++ src/config/constants.ts | 18 ++ src/config/env.ts | 26 ++ src/db/connection.ts | 6 + src/db/knexfile.ts | 22 ++ src/db/migrations/001_create_users.ts | 27 ++ .../002_create_password_reset_tokens.ts | 18 ++ src/db/migrations/003_create_pantry_items.ts | 22 ++ src/db/migrations/004_create_recipes.ts | 30 ++ .../migrations/005_create_shopping_lists.ts | 36 +++ .../migrations/006_create_deleted_records.ts | 17 ++ src/db/seeds/001_recipes.ts | 164 +++++++++++ src/jobs/cronJobs.ts | 34 +++ src/middleware/auth.ts | 52 ++++ src/middleware/errorHandler.ts | 47 +++ src/routes/auth.ts | 134 +++++++++ src/routes/pantry.ts | 51 ++++ src/routes/recipes.ts | 31 ++ src/routes/shoppingLists.ts | 118 ++++++++ src/routes/sync.ts | 20 ++ src/server.ts | 24 ++ src/services/authService.ts | 254 ++++++++++++++++ src/services/emailService.ts | 29 ++ src/services/pantryService.ts | 73 +++++ src/services/recipeService.ts | 136 +++++++++ src/services/shoppingListService.ts | 267 +++++++++++++++++ src/services/syncService.ts | 75 +++++ src/test/helpers.ts | 37 +++ src/test/routes/auth.test.ts | 184 ++++++++++++ src/test/routes/pantry.test.ts | 163 +++++++++++ src/test/routes/recipes.test.ts | 148 ++++++++++ src/test/routes/shoppingLists.test.ts | 277 ++++++++++++++++++ src/test/routes/sync.test.ts | 87 ++++++ src/test/setup.ts | 4 + src/test/utils.test.ts | 27 ++ src/test/validators.test.ts | 158 ++++++++++ src/utils/jwt.ts | 17 ++ src/utils/logger.ts | 15 + src/utils/validators.ts | 97 ++++++ tsconfig.json | 19 ++ 44 files changed, 3106 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/config/constants.ts create mode 100644 src/config/env.ts create mode 100644 src/db/connection.ts create mode 100644 src/db/knexfile.ts create mode 100644 src/db/migrations/001_create_users.ts create mode 100644 src/db/migrations/002_create_password_reset_tokens.ts create mode 100644 src/db/migrations/003_create_pantry_items.ts create mode 100644 src/db/migrations/004_create_recipes.ts create mode 100644 src/db/migrations/005_create_shopping_lists.ts create mode 100644 src/db/migrations/006_create_deleted_records.ts create mode 100644 src/db/seeds/001_recipes.ts create mode 100644 src/jobs/cronJobs.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/middleware/errorHandler.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/pantry.ts create mode 100644 src/routes/recipes.ts create mode 100644 src/routes/shoppingLists.ts create mode 100644 src/routes/sync.ts create mode 100644 src/server.ts create mode 100644 src/services/authService.ts create mode 100644 src/services/emailService.ts create mode 100644 src/services/pantryService.ts create mode 100644 src/services/recipeService.ts create mode 100644 src/services/shoppingListService.ts create mode 100644 src/services/syncService.ts create mode 100644 src/test/helpers.ts create mode 100644 src/test/routes/auth.test.ts create mode 100644 src/test/routes/pantry.test.ts create mode 100644 src/test/routes/recipes.test.ts create mode 100644 src/test/routes/shoppingLists.test.ts create mode 100644 src/test/routes/sync.test.ts create mode 100644 src/test/setup.ts create mode 100644 src/test/utils.test.ts create mode 100644 src/test/validators.test.ts create mode 100644 src/utils/jwt.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/validators.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..78e7635 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Server +NODE_ENV=development +PORT=3000 + +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/pantree + +# JWT +JWT_SECRET=change_this_to_a_long_random_secret_at_least_64_chars +JWT_EXPIRES_IN=24h + +# Google OAuth +GOOGLE_CLIENT_ID=your_google_client_id_here + +# SendGrid +SENDGRID_API_KEY=your_sendgrid_api_key_here +SENDGRID_FROM_EMAIL=noreply@pantree.app + +# App +FRONTEND_URL=https://pantree.app +PASSWORD_RESET_URL=https://pantree.app/reset-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85de54b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +coverage/ +.DS_Store diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6157fe2 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,24 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.test.ts', + '!src/db/knexfile.ts', + '!src/db/migrations/**', + '!src/db/seeds/**', + '!src/server.ts' + ], + coverageThreshold: { + global: { + branches: 70, + functions: 80, + lines: 80, + statements: 80 + } + }, + setupFiles: ['/src/test/setup.ts'] +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..06c6b06 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "pantree-backend", + "version": "1.0.0", + "description": "Pantree API Server", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "test": "jest --runInBand --forceExit", + "test:coverage": "jest --runInBand --forceExit --coverage", + "migrate": "knex migrate:latest --knexfile src/db/knexfile.ts", + "migrate:rollback": "knex migrate:rollback --knexfile src/db/knexfile.ts", + "seed": "knex seed:run --knexfile src/db/knexfile.ts" + }, + "dependencies": { + "@sendgrid/mail": "^8.1.0", + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "google-auth-library": "^9.10.0", + "jsonwebtoken": "^9.0.2", + "knex": "^3.1.0", + "node-cron": "^3.0.3", + "pg": "^8.11.5", + "pino": "^9.1.0", + "pino-http": "^10.1.0", + "uuid": "^9.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.12.12", + "@types/node-cron": "^3.0.11", + "@types/supertest": "^6.0.2", + "@types/uuid": "^9.0.8", + "jest": "^29.7.0", + "supertest": "^7.0.0", + "ts-jest": "^29.1.4", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.5" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..f69d3ee --- /dev/null +++ b/src/app.ts @@ -0,0 +1,42 @@ +import express from 'express'; +import cors from 'cors'; +import { httpLogger } from './utils/logger'; +import { errorHandler } from './middleware/errorHandler'; +import authRoutes from './routes/auth'; +import pantryRoutes from './routes/pantry'; +import recipeRoutes from './routes/recipes'; +import shoppingListRoutes from './routes/shoppingLists'; +import syncRoutes from './routes/sync'; + +export function createApp() { + const app = express(); + + app.use(cors()); + app.use(express.json()); + app.use(httpLogger); + + // Health check + app.get('/health', (_req, res) => { + res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + + // Routes + app.use('/v1/auth', authRoutes); + app.use('/v1/pantry', pantryRoutes); + app.use('/v1/recipes', recipeRoutes); + app.use('/v1/shopping-lists', shoppingListRoutes); + app.use('/v1/sync', syncRoutes); + + // 404 handler + app.use((_req, res) => { + res.status(404).json({ + error: 'Route not found.', + code: 'NOT_FOUND', + timestamp: new Date().toISOString(), + }); + }); + + app.use(errorHandler); + + return app; +} diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..95d9a1f --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,18 @@ +export const ALLOWED_UNITS = [ + 'cups', 'tbsp', 'tsp', 'oz', 'fl_oz', + 'g', 'kg', 'ml', 'l', + 'pieces', 'slices', 'cloves', 'pinch', + 'whole', 'can', 'package', 'bunch' +] as const; + +export type AllowedUnit = typeof ALLOWED_UNITS[number]; + +export const BCRYPT_ROUNDS = 12; +export const JWT_EXPIRES_IN = '24h'; +export const PASSWORD_RESET_EXPIRES_HOURS = 1; +export const ACCOUNT_DELETION_DAYS = 15; +export const TOMBSTONE_RETENTION_DAYS = 30; +export const MAX_RECIPE_SCALE = 3; +export const MIN_RECIPE_SCALE = 1; +export const DEFAULT_PAGE_LIMIT = 20; +export const MAX_PAGE_LIMIT = 50; diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..2dd5ee3 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,26 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +export const config = { + nodeEnv: process.env.NODE_ENV ?? 'development', + port: parseInt(process.env.PORT ?? '3000', 10), + databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test', + jwtSecret: process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever', + jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h', + googleClientId: process.env.GOOGLE_CLIENT_ID ?? '', + sendgridApiKey: process.env.SENDGRID_API_KEY ?? '', + sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app', + frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000', + passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password', + isTest: process.env.NODE_ENV === 'test', + isDev: process.env.NODE_ENV === 'development', + isProd: process.env.NODE_ENV === 'production', +}; diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..b69b0a3 --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,6 @@ +import knex from 'knex'; +import knexConfig from './knexfile'; + +const db = knex(knexConfig); + +export default db; diff --git a/src/db/knexfile.ts b/src/db/knexfile.ts new file mode 100644 index 0000000..2e876ef --- /dev/null +++ b/src/db/knexfile.ts @@ -0,0 +1,22 @@ +import type { Knex } from 'knex'; +import { config } from '../config/env'; + +const knexConfig: Knex.Config = { + client: 'pg', + connection: config.databaseUrl, + migrations: { + directory: './migrations', + extension: 'ts', + }, + seeds: { + directory: './seeds', + extension: 'ts', + }, + pool: { + min: 2, + max: 10, + }, +}; + +export default knexConfig; +module.exports = knexConfig; diff --git a/src/db/migrations/001_create_users.ts b/src/db/migrations/001_create_users.ts new file mode 100644 index 0000000..e89a574 --- /dev/null +++ b/src/db/migrations/001_create_users.ts @@ -0,0 +1,27 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.raw('CREATE EXTENSION IF NOT EXISTS "pgcrypto"'); + + await knex.schema.createTable('users', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('email', 255).notNullable(); + table.string('password_hash', 255).nullable(); + table.string('name', 255).notNullable(); + table.text('profile_picture_url').nullable(); + table.string('google_id', 255).nullable(); + table.boolean('email_verified').defaultTo(false); + table.timestamp('deleted_at', { useTz: true }).nullable(); + table.timestamp('deletion_scheduled_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.raw(`CREATE UNIQUE INDEX idx_users_email ON users (LOWER(email))`); + await knex.raw(`CREATE UNIQUE INDEX idx_users_google_id ON users (google_id) WHERE google_id IS NOT NULL`); + await knex.raw(`CREATE INDEX idx_users_deletion_scheduled ON users (deletion_scheduled_at) WHERE deletion_scheduled_at IS NOT NULL`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('users'); +} diff --git a/src/db/migrations/002_create_password_reset_tokens.ts b/src/db/migrations/002_create_password_reset_tokens.ts new file mode 100644 index 0000000..84f2861 --- /dev/null +++ b/src/db/migrations/002_create_password_reset_tokens.ts @@ -0,0 +1,18 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('password_reset_tokens', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE'); + table.string('token_hash', 255).notNullable(); + table.timestamp('expires_at', { useTz: true }).notNullable(); + table.timestamp('used_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('password_reset_tokens'); +} diff --git a/src/db/migrations/003_create_pantry_items.ts b/src/db/migrations/003_create_pantry_items.ts new file mode 100644 index 0000000..91a1f3a --- /dev/null +++ b/src/db/migrations/003_create_pantry_items.ts @@ -0,0 +1,22 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('pantry_items', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE'); + table.string('item_name', 255).notNullable(); + table.string('item_name_lower', 255).notNullable(); + table.integer('quantity').notNullable().checkPositive(); + table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.raw(`CREATE UNIQUE INDEX idx_pantry_user_item ON pantry_items (user_id, item_name_lower)`); + await knex.raw(`CREATE INDEX idx_pantry_user_id ON pantry_items (user_id)`); + await knex.raw(`CREATE INDEX idx_pantry_last_modified ON pantry_items (user_id, last_modified)`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('pantry_items'); +} diff --git a/src/db/migrations/004_create_recipes.ts b/src/db/migrations/004_create_recipes.ts new file mode 100644 index 0000000..cb7a7f7 --- /dev/null +++ b/src/db/migrations/004_create_recipes.ts @@ -0,0 +1,30 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('recipes', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name', 255).notNullable(); + table.text('instructions').notNullable(); + table.integer('servings').notNullable().checkPositive(); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.schema.createTable('recipe_ingredients', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('recipe_id').notNullable().references('id').inTable('recipes').onDelete('CASCADE'); + table.string('item_name', 255).notNullable(); + table.string('item_name_lower', 255).notNullable(); + table.decimal('quantity', 10, 4).notNullable().checkPositive(); + table.string('unit', 50).notNullable(); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.raw(`CREATE INDEX idx_recipe_ingredients_recipe ON recipe_ingredients (recipe_id)`); + await knex.raw(`CREATE INDEX idx_recipe_ingredients_name ON recipe_ingredients (item_name_lower)`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('recipe_ingredients'); + await knex.schema.dropTableIfExists('recipes'); +} diff --git a/src/db/migrations/005_create_shopping_lists.ts b/src/db/migrations/005_create_shopping_lists.ts new file mode 100644 index 0000000..14793c5 --- /dev/null +++ b/src/db/migrations/005_create_shopping_lists.ts @@ -0,0 +1,36 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('shopping_lists', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE'); + table.string('list_name', 255).notNullable(); + table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.schema.createTable('shopping_list_items', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('shopping_list_id').notNullable().references('id').inTable('shopping_lists').onDelete('CASCADE'); + table.string('item_name', 255).notNullable(); + table.string('item_name_lower', 255).notNullable(); + table.decimal('quantity', 10, 4).notNullable().checkPositive(); + table.string('unit', 50).notNullable(); + table.boolean('checked_off').defaultTo(false); + table.timestamp('last_modified', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); + table.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.raw(`CREATE INDEX idx_shopping_lists_user ON shopping_lists (user_id)`); + await knex.raw(`CREATE INDEX idx_shopping_lists_last_modified ON shopping_lists (user_id, last_modified)`); + await knex.raw(`CREATE INDEX idx_list_items_list ON shopping_list_items (shopping_list_id)`); + await knex.raw(`CREATE INDEX idx_list_items_name_unit ON shopping_list_items (shopping_list_id, item_name_lower, unit)`); + await knex.raw(`CREATE INDEX idx_list_items_last_modified ON shopping_list_items (shopping_list_id, last_modified)`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('shopping_list_items'); + await knex.schema.dropTableIfExists('shopping_lists'); +} diff --git a/src/db/migrations/006_create_deleted_records.ts b/src/db/migrations/006_create_deleted_records.ts new file mode 100644 index 0000000..934bfe3 --- /dev/null +++ b/src/db/migrations/006_create_deleted_records.ts @@ -0,0 +1,17 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('deleted_records', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').notNullable(); + table.string('table_name', 50).notNullable(); + table.uuid('record_id').notNullable(); + table.timestamp('deleted_at', { useTz: true }).defaultTo(knex.fn.now()); + }); + + await knex.raw(`CREATE INDEX idx_deleted_records_user_time ON deleted_records (user_id, deleted_at)`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('deleted_records'); +} diff --git a/src/db/seeds/001_recipes.ts b/src/db/seeds/001_recipes.ts new file mode 100644 index 0000000..445e1df --- /dev/null +++ b/src/db/seeds/001_recipes.ts @@ -0,0 +1,164 @@ +import type { Knex } from 'knex'; + +const recipes = [ + { + name: 'Chocolate Chip Cookies', + servings: 24, + instructions: '1. Preheat oven to 375°F.\n2. Cream butter and sugars.\n3. Beat in eggs and vanilla.\n4. Mix in flour, baking soda, and salt.\n5. Stir in chocolate chips.\n6. Drop by spoonfuls onto baking sheet.\n7. Bake 9-11 minutes until golden.', + ingredients: [ + { item_name: 'All-Purpose Flour', quantity: 2.25, unit: 'cups' }, + { item_name: 'Butter', quantity: 1, unit: 'cups' }, + { item_name: 'Granulated Sugar', quantity: 0.75, unit: 'cups' }, + { item_name: 'Brown Sugar', quantity: 0.75, unit: 'cups' }, + { item_name: 'Eggs', quantity: 2, unit: 'pieces' }, + { item_name: 'Vanilla Extract', quantity: 1, unit: 'tsp' }, + { item_name: 'Baking Soda', quantity: 1, unit: 'tsp' }, + { item_name: 'Chocolate Chips', quantity: 2, unit: 'cups' }, + ], + }, + { + name: 'Classic Pancakes', + servings: 4, + instructions: '1. Mix dry ingredients.\n2. Whisk wet ingredients separately.\n3. Combine wet and dry.\n4. Cook on greased griddle over medium heat.\n5. Flip when bubbles form.', + ingredients: [ + { item_name: 'All-Purpose Flour', quantity: 1.5, unit: 'cups' }, + { item_name: 'Milk', quantity: 1.25, unit: 'cups' }, + { item_name: 'Eggs', quantity: 1, unit: 'pieces' }, + { item_name: 'Butter', quantity: 3, unit: 'tbsp' }, + { item_name: 'Baking Powder', quantity: 2, unit: 'tsp' }, + { item_name: 'Granulated Sugar', quantity: 1, unit: 'tbsp' }, + { item_name: 'Salt', quantity: 1, unit: 'tsp' }, + ], + }, + { + name: 'Spaghetti Bolognese', + servings: 4, + instructions: '1. Brown ground beef.\n2. Add onion and garlic, cook until soft.\n3. Add tomatoes and simmer 30 minutes.\n4. Cook pasta.\n5. Serve sauce over pasta.', + ingredients: [ + { item_name: 'Spaghetti', quantity: 400, unit: 'g' }, + { item_name: 'Ground Beef', quantity: 500, unit: 'g' }, + { item_name: 'Onion', quantity: 1, unit: 'whole' }, + { item_name: 'Garlic', quantity: 3, unit: 'cloves' }, + { item_name: 'Canned Tomatoes', quantity: 2, unit: 'can' }, + { item_name: 'Olive Oil', quantity: 2, unit: 'tbsp' }, + { item_name: 'Salt', quantity: 1, unit: 'tsp' }, + ], + }, + { + name: 'Caesar Salad', + servings: 2, + instructions: '1. Wash and chop romaine.\n2. Make dressing with garlic, lemon, and parmesan.\n3. Toss lettuce with dressing.\n4. Top with croutons and extra parmesan.', + ingredients: [ + { item_name: 'Romaine Lettuce', quantity: 1, unit: 'whole' }, + { item_name: 'Parmesan Cheese', quantity: 0.5, unit: 'cups' }, + { item_name: 'Garlic', quantity: 2, unit: 'cloves' }, + { item_name: 'Lemon', quantity: 1, unit: 'whole' }, + { item_name: 'Olive Oil', quantity: 3, unit: 'tbsp' }, + { item_name: 'Croutons', quantity: 1, unit: 'cups' }, + ], + }, + { + name: 'Banana Bread', + servings: 8, + instructions: '1. Preheat oven to 350°F.\n2. Mash bananas.\n3. Mix wet ingredients.\n4. Fold in dry ingredients.\n5. Pour into loaf pan.\n6. Bake 60-65 minutes.', + ingredients: [ + { item_name: 'Ripe Bananas', quantity: 3, unit: 'whole' }, + { item_name: 'All-Purpose Flour', quantity: 1.5, unit: 'cups' }, + { item_name: 'Butter', quantity: 0.33, unit: 'cups' }, + { item_name: 'Granulated Sugar', quantity: 0.75, unit: 'cups' }, + { item_name: 'Eggs', quantity: 1, unit: 'pieces' }, + { item_name: 'Baking Soda', quantity: 1, unit: 'tsp' }, + { item_name: 'Salt', quantity: 0.25, unit: 'tsp' }, + ], + }, + { + name: 'Chicken Stir Fry', + servings: 4, + instructions: '1. Slice chicken and vegetables.\n2. Heat oil in wok.\n3. Cook chicken until done.\n4. Add vegetables and stir fry.\n5. Add sauce and toss.\n6. Serve over rice.', + ingredients: [ + { item_name: 'Chicken Breast', quantity: 500, unit: 'g' }, + { item_name: 'Bell Pepper', quantity: 2, unit: 'whole' }, + { item_name: 'Broccoli', quantity: 2, unit: 'cups' }, + { item_name: 'Soy Sauce', quantity: 3, unit: 'tbsp' }, + { item_name: 'Garlic', quantity: 3, unit: 'cloves' }, + { item_name: 'Vegetable Oil', quantity: 2, unit: 'tbsp' }, + { item_name: 'Rice', quantity: 2, unit: 'cups' }, + ], + }, + { + name: 'Guacamole', + servings: 4, + instructions: '1. Halve and pit avocados.\n2. Scoop flesh into bowl.\n3. Mash with fork.\n4. Add lime juice, salt, onion, cilantro.\n5. Mix and adjust seasoning.', + ingredients: [ + { item_name: 'Avocado', quantity: 3, unit: 'whole' }, + { item_name: 'Lime', quantity: 1, unit: 'whole' }, + { item_name: 'Red Onion', quantity: 0.25, unit: 'whole' }, + { item_name: 'Cilantro', quantity: 2, unit: 'tbsp' }, + { item_name: 'Salt', quantity: 0.5, unit: 'tsp' }, + ], + }, + { + name: 'Tomato Soup', + servings: 4, + instructions: '1. Sauté onion and garlic.\n2. Add tomatoes and broth.\n3. Simmer 20 minutes.\n4. Blend until smooth.\n5. Season with salt and pepper.', + ingredients: [ + { item_name: 'Canned Tomatoes', quantity: 2, unit: 'can' }, + { item_name: 'Onion', quantity: 1, unit: 'whole' }, + { item_name: 'Garlic', quantity: 2, unit: 'cloves' }, + { item_name: 'Vegetable Broth', quantity: 2, unit: 'cups' }, + { item_name: 'Olive Oil', quantity: 2, unit: 'tbsp' }, + { item_name: 'Salt', quantity: 1, unit: 'tsp' }, + ], + }, + { + name: 'French Toast', + servings: 2, + instructions: '1. Whisk eggs, milk, and cinnamon.\n2. Dip bread slices.\n3. Cook on buttered pan until golden.\n4. Serve with maple syrup.', + ingredients: [ + { item_name: 'Bread', quantity: 4, unit: 'slices' }, + { item_name: 'Eggs', quantity: 2, unit: 'pieces' }, + { item_name: 'Milk', quantity: 0.25, unit: 'cups' }, + { item_name: 'Butter', quantity: 1, unit: 'tbsp' }, + { item_name: 'Cinnamon', quantity: 0.5, unit: 'tsp' }, + ], + }, + { + name: 'Oatmeal', + servings: 1, + instructions: '1. Bring water or milk to boil.\n2. Add oats.\n3. Cook 5 minutes stirring occasionally.\n4. Top with fruit and honey.', + ingredients: [ + { item_name: 'Rolled Oats', quantity: 0.5, unit: 'cups' }, + { item_name: 'Milk', quantity: 1, unit: 'cups' }, + { item_name: 'Honey', quantity: 1, unit: 'tbsp' }, + { item_name: 'Salt', quantity: 1, unit: 'pinch' }, + ], + }, +]; + +export async function seed(knex: Knex): Promise { + // Clear existing + await knex('recipe_ingredients').delete(); + await knex('recipes').delete(); + + for (const recipe of recipes) { + const [inserted] = await knex('recipes') + .insert({ + name: recipe.name, + servings: recipe.servings, + instructions: recipe.instructions, + }) + .returning('id'); + + const recipeId = inserted.id; + + await knex('recipe_ingredients').insert( + recipe.ingredients.map((ing) => ({ + recipe_id: recipeId, + item_name: ing.item_name, + item_name_lower: ing.item_name.toLowerCase(), + quantity: ing.quantity, + unit: ing.unit, + })) + ); + } +} diff --git a/src/jobs/cronJobs.ts b/src/jobs/cronJobs.ts new file mode 100644 index 0000000..c85f9ff --- /dev/null +++ b/src/jobs/cronJobs.ts @@ -0,0 +1,34 @@ +import cron from 'node-cron'; +import db from '../db/connection'; +import { logger } from '../utils/logger'; +import { syncService } from '../services/syncService'; + +let lastCronRun: Date | null = null; + +export function getLastCronRun(): Date | null { + return lastCronRun; +} + +export function startCronJobs(): void { + // Daily at 2:00 AM UTC — hard-delete expired accounts + cron.schedule('0 2 * * *', async () => { + logger.info('Running daily account hard-delete job...'); + try { + const result = await db('users') + .where('deletion_scheduled_at', '<=', db.fn.now()) + .whereNotNull('deletion_scheduled_at') + .delete(); + + logger.info({ deletedCount: result }, 'Hard-delete job complete.'); + + // Clean up old tombstones + await syncService.cleanupTombstones(); + + lastCronRun = new Date(); + } catch (err) { + logger.error({ err }, 'Hard-delete cron job failed.'); + } + }); + + logger.info('Cron jobs registered.'); +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..329f3ad --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../config/env'; +import { createError } from './errorHandler'; +import db from '../db/connection'; + +export interface AuthenticatedRequest extends Request { + userId?: string; +} + +export async function authMiddleware( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next(createError('Missing or invalid Authorization header.', 401, 'UNAUTHORIZED')); + } + + const token = authHeader.slice(7); + let payload: { userId: string }; + + try { + payload = jwt.verify(token, config.jwtSecret) as { userId: string }; + } catch { + return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); + } + + // Verify user still exists and is not hard-deleted + const user = await db('users') + .where({ id: payload.userId }) + .whereNull('deletion_scheduled_at') + .select('id', 'deleted_at', 'deletion_scheduled_at') + .first(); + + if (!user) { + return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); + } + + // Block access for soft-deleted accounts (except restore endpoint) + if (user.deleted_at && !req.path.includes('/restore-account')) { + return next(createError('Account is pending deletion.', 403, 'FORBIDDEN')); + } + + req.userId = payload.userId; + next(); + } catch (err) { + next(err); + } +} diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts new file mode 100644 index 0000000..6148357 --- /dev/null +++ b/src/middleware/errorHandler.ts @@ -0,0 +1,47 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodError } from 'zod'; +import { logger } from '../utils/logger'; + +export interface AppError extends Error { + statusCode?: number; + code?: string; + details?: unknown; +} + +export function createError(message: string, statusCode: number, code: string): AppError { + const err: AppError = new Error(message); + err.statusCode = statusCode; + err.code = code; + return err; +} + +export function errorHandler( + err: AppError, + _req: Request, + res: Response, + _next: NextFunction +): void { + const timestamp = new Date().toISOString(); + + if (err instanceof ZodError) { + res.status(400).json({ + error: err.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; '), + code: 'VALIDATION_ERROR', + timestamp, + }); + return; + } + + const statusCode = err.statusCode ?? 500; + const code = err.code ?? 'INTERNAL_ERROR'; + + if (statusCode >= 500) { + logger.error({ err }, 'Unhandled server error'); + } + + res.status(statusCode).json({ + error: statusCode >= 500 ? 'An internal error occurred.' : err.message, + code, + timestamp, + }); +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..f7088fa --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,134 @@ +import { Router, Response, NextFunction } from 'express'; +import { authService } from '../services/authService'; +import { AuthenticatedRequest, authMiddleware } from '../middleware/auth'; +import { + signupSchema, + signinSchema, + googleAuthSchema, + passwordResetRequestSchema, + passwordResetConfirmSchema, +} from '../utils/validators'; + +const router = Router(); + +// POST /auth/signup +router.post('/signup', async (req, res: Response, next: NextFunction) => { + try { + const { email, password, name } = signupSchema.parse(req.body); + const result = await authService.signup(email, password, name); + res.status(201).json(result); + } catch (err) { + next(err); + } +}); + +// POST /auth/signin +router.post('/signin', async (req, res: Response, next: NextFunction) => { + try { + const { email, password } = signinSchema.parse(req.body); + const result = await authService.signin(email, password); + res.status(200).json(result); + } catch (err: unknown) { + if ( + err instanceof Error && + (err as { code?: string }).code === 'ACCOUNT_PENDING_DELETION' + ) { + const appErr = err as { code?: string; statusCode?: number; deletion_scheduled_at?: string }; + res.status(403).json({ + error: err.message, + code: appErr.code, + deletion_scheduled_at: appErr.deletion_scheduled_at, + timestamp: new Date().toISOString(), + }); + return; + } + next(err); + } +}); + +// POST /auth/google +router.post('/google', async (req, res: Response, next: NextFunction) => { + try { + const { id_token } = googleAuthSchema.parse(req.body); + const result = await authService.googleAuth(id_token); + const statusCode = result.is_new_user ? 201 : 200; + res.status(statusCode).json(result); + } catch (err: unknown) { + if ( + err instanceof Error && + (err as { code?: string }).code === 'ACCOUNT_PENDING_DELETION' + ) { + const appErr = err as { code?: string; statusCode?: number; deletion_scheduled_at?: string }; + res.status(403).json({ + error: err.message, + code: appErr.code, + deletion_scheduled_at: appErr.deletion_scheduled_at, + timestamp: new Date().toISOString(), + }); + return; + } + next(err); + } +}); + +// POST /auth/password-reset +router.post('/password-reset', async (req, res: Response, next: NextFunction) => { + try { + const { email } = passwordResetRequestSchema.parse(req.body); + await authService.requestPasswordReset(email); + res.status(200).json({ + message: 'If an account exists with this email, a reset link has been sent.', + timestamp: new Date().toISOString(), + }); + } catch (err) { + next(err); + } +}); + +// PUT /auth/password-reset +router.put('/password-reset', async (req, res: Response, next: NextFunction) => { + try { + const { token, new_password } = passwordResetConfirmSchema.parse(req.body); + await authService.confirmPasswordReset(token, new_password); + res.status(200).json({ + message: 'Password updated successfully.', + timestamp: new Date().toISOString(), + }); + } catch (err) { + next(err); + } +}); + +// DELETE /auth/account (protected) +router.delete( + '/account', + authMiddleware, + async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + await authService.deleteAccount(req.userId!); + res.status(204).send(); + } catch (err) { + next(err); + } + } +); + +// POST /auth/restore-account (protected) +router.post( + '/restore-account', + authMiddleware, + async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const user = await authService.restoreAccount(req.userId!); + res.status(200).json({ + user, + message: 'Account restored successfully.', + timestamp: new Date().toISOString(), + }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/src/routes/pantry.ts b/src/routes/pantry.ts new file mode 100644 index 0000000..335a607 --- /dev/null +++ b/src/routes/pantry.ts @@ -0,0 +1,51 @@ +import { Router, Response, NextFunction } from 'express'; +import { authMiddleware, AuthenticatedRequest } from '../middleware/auth'; +import { pantryService } from '../services/pantryService'; +import { addPantryItemSchema, updatePantryItemSchema } from '../utils/validators'; + +const router = Router(); +router.use(authMiddleware); + +// GET /pantry +router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const items = await pantryService.getItems(req.userId!); + res.status(200).json({ items, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// POST /pantry +router.post('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { item_name, quantity } = addPantryItemSchema.parse(req.body); + const item = await pantryService.addItem(req.userId!, item_name, quantity); + res.status(201).json({ item, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// PUT /pantry/:item_id +router.put('/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { quantity } = updatePantryItemSchema.parse(req.body); + const item = await pantryService.updateItem(req.userId!, req.params.item_id, quantity); + res.status(200).json({ item, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// DELETE /pantry/:item_id +router.delete('/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + await pantryService.deleteItem(req.userId!, req.params.item_id); + res.status(204).send(); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/routes/recipes.ts b/src/routes/recipes.ts new file mode 100644 index 0000000..4ac4851 --- /dev/null +++ b/src/routes/recipes.ts @@ -0,0 +1,31 @@ +import { Router, Response, NextFunction } from 'express'; +import { authMiddleware, AuthenticatedRequest } from '../middleware/auth'; +import { recipeService } from '../services/recipeService'; +import { recipeQuerySchema, recipeDetailQuerySchema } from '../utils/validators'; + +const router = Router(); +router.use(authMiddleware); + +// GET /recipes +router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { filter, page, limit, search } = recipeQuerySchema.parse(req.query); + const result = await recipeService.getRecipes(req.userId!, filter, page, limit, search); + res.status(200).json({ ...result, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// GET /recipes/:recipe_id +router.get('/:recipe_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { scale } = recipeDetailQuerySchema.parse(req.query); + const recipe = await recipeService.getRecipeById(req.params.recipe_id, req.userId!, scale); + res.status(200).json({ recipe }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/routes/shoppingLists.ts b/src/routes/shoppingLists.ts new file mode 100644 index 0000000..d4d5f63 --- /dev/null +++ b/src/routes/shoppingLists.ts @@ -0,0 +1,118 @@ +import { Router, Response, NextFunction } from 'express'; +import { authMiddleware, AuthenticatedRequest } from '../middleware/auth'; +import { shoppingListService } from '../services/shoppingListService'; +import { + createShoppingListSchema, + addShoppingListItemSchema, + updateShoppingListItemSchema, + addRecipesToListSchema, +} from '../utils/validators'; + +const router = Router(); +router.use(authMiddleware); + +// GET /shopping-lists +router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const lists = await shoppingListService.getLists(req.userId!); + res.status(200).json({ shopping_lists: lists, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// POST /shopping-lists +router.post('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { list_name } = createShoppingListSchema.parse(req.body); + const list = await shoppingListService.createList(req.userId!, list_name); + res.status(201).json({ shopping_list: list }); + } catch (err) { + next(err); + } +}); + +// GET /shopping-lists/:list_id +router.get('/:list_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const list = await shoppingListService.getListById(req.params.list_id, req.userId!); + res.status(200).json({ shopping_list: list, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// DELETE /shopping-lists/:list_id +router.delete('/:list_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + await shoppingListService.deleteList(req.params.list_id, req.userId!); + res.status(204).send(); + } catch (err) { + next(err); + } +}); + +// POST /shopping-lists/:list_id/items +router.post('/:list_id/items', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { item_name, quantity, unit } = addShoppingListItemSchema.parse(req.body); + const result = await shoppingListService.addItem( + req.params.list_id, + req.userId!, + item_name, + quantity, + unit + ); + res.status(201).json(result); + } catch (err) { + next(err); + } +}); + +// POST /shopping-lists/:list_id/add-recipes +router.post('/:list_id/add-recipes', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { recipe_ids, scale_factor } = addRecipesToListSchema.parse(req.body); + const result = await shoppingListService.addRecipesToList( + req.params.list_id, + req.userId!, + recipe_ids, + scale_factor + ); + res.status(201).json(result); + } catch (err) { + next(err); + } +}); + +// PUT /shopping-lists/:list_id/items/:item_id +router.put('/:list_id/items/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const updates = updateShoppingListItemSchema.parse(req.body); + const item = await shoppingListService.updateItem( + req.params.list_id, + req.params.item_id, + req.userId!, + updates + ); + res.status(200).json({ item, synced_at: new Date().toISOString() }); + } catch (err) { + next(err); + } +}); + +// DELETE /shopping-lists/:list_id/items/:item_id +router.delete('/:list_id/items/:item_id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + await shoppingListService.deleteItem( + req.params.list_id, + req.params.item_id, + req.userId! + ); + res.status(204).send(); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/routes/sync.ts b/src/routes/sync.ts new file mode 100644 index 0000000..6570335 --- /dev/null +++ b/src/routes/sync.ts @@ -0,0 +1,20 @@ +import { Router, Response, NextFunction } from 'express'; +import { authMiddleware, AuthenticatedRequest } from '../middleware/auth'; +import { syncService } from '../services/syncService'; +import { syncQuerySchema } from '../utils/validators'; + +const router = Router(); +router.use(authMiddleware); + +// GET /sync +router.get('/', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { since } = syncQuerySchema.parse(req.query); + const delta = await syncService.getDelta(req.userId!, since); + res.status(200).json(delta); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..bc58532 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,24 @@ +import { createApp } from './app'; +import { config } from './config/env'; +import { logger } from './utils/logger'; +import { startCronJobs } from './jobs/cronJobs'; +import db from './db/connection'; + +async function main() { + // Verify DB connection + await db.raw('SELECT 1'); + logger.info('Database connection established.'); + + const app = createApp(); + + app.listen(config.port, () => { + logger.info(`Pantree API running on port ${config.port}`); + }); + + startCronJobs(); +} + +main().catch((err) => { + logger.error({ err }, 'Failed to start server'); + process.exit(1); +}); diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..b6987d2 --- /dev/null +++ b/src/services/authService.ts @@ -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) { + 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).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).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); + }, +}; diff --git a/src/services/emailService.ts b/src/services/emailService.ts new file mode 100644 index 0000000..ad1e9c0 --- /dev/null +++ b/src/services/emailService.ts @@ -0,0 +1,29 @@ +import { config } from '../config/env'; +import { logger } from '../utils/logger'; + +export const emailService = { + async sendPasswordReset(toEmail: string, rawToken: string): Promise { + 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: `

Click the link to reset your password:

${resetUrl}

This link expires in 1 hour.

`, + }); + } catch (err) { + logger.error({ err }, 'Failed to send password reset email'); + // Do not throw — prevents email enumeration via timing + } + }, +}; diff --git a/src/services/pantryService.ts b/src/services/pantryService.ts new file mode 100644 index 0000000..2f8457d --- /dev/null +++ b/src/services/pantryService.ts @@ -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, + }); + }, +}; diff --git a/src/services/recipeService.ts b/src/services/recipeService.ts new file mode 100644 index 0000000..9190389 --- /dev/null +++ b/src/services/recipeService.ts @@ -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(); + 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, + }; + }, +}; diff --git a/src/services/shoppingListService.ts b/src/services/shoppingListService.ts new file mode 100644 index 0000000..4089034 --- /dev/null +++ b/src/services/shoppingListService.ts @@ -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 = { + 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() }); + }, +}; diff --git a/src/services/syncService.ts b/src/services/syncService.ts new file mode 100644 index 0000000..eb2da09 --- /dev/null +++ b/src/services/syncService.ts @@ -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(); + }, +}; diff --git a/src/test/helpers.ts b/src/test/helpers.ts new file mode 100644 index 0000000..25ea72f --- /dev/null +++ b/src/test/helpers.ts @@ -0,0 +1,37 @@ +import db from '../db/connection'; +import bcrypt from 'bcrypt'; + +export async function createTestUser(overrides: Partial<{ + email: string; + password: string; + name: string; + deleted_at: string | null; + deletion_scheduled_at: string | null; +}> = {}) { + const email = overrides.email ?? `test_${Date.now()}@example.com`; + const password = overrides.password ?? 'password123'; + const name = overrides.name ?? 'Test User'; + + const password_hash = await bcrypt.hash(password, 4); // Low rounds for test speed + + const [user] = await db('users') + .insert({ + email: email.toLowerCase(), + password_hash, + name, + deleted_at: overrides.deleted_at ?? null, + deletion_scheduled_at: overrides.deletion_scheduled_at ?? null, + }) + .returning('*'); + + return { user, password }; +} + +export async function cleanupTestData() { + await db('deleted_records').delete(); + await db('shopping_list_items').delete(); + await db('shopping_lists').delete(); + await db('pantry_items').delete(); + await db('password_reset_tokens').delete(); + await db('users').delete(); +} diff --git a/src/test/routes/auth.test.ts b/src/test/routes/auth.test.ts new file mode 100644 index 0000000..5782962 --- /dev/null +++ b/src/test/routes/auth.test.ts @@ -0,0 +1,184 @@ +import request from 'supertest'; +import { createApp } from '../../app'; +import db from '../../db/connection'; +import { createTestUser, cleanupTestData } from '../helpers'; +import { signToken } from '../../utils/jwt'; + +const app = createApp(); + +beforeEach(async () => { + await cleanupTestData(); +}); + +afterAll(async () => { + await cleanupTestData(); + await db.destroy(); +}); + +describe('POST /v1/auth/signup', () => { + it('creates a new user and returns token', async () => { + const res = await request(app).post('/v1/auth/signup').send({ + email: 'newuser@example.com', + password: 'password123', + name: 'New User', + }); + + expect(res.status).toBe(201); + expect(res.body.user.email).toBe('newuser@example.com'); + expect(res.body.token).toBeDefined(); + expect(res.body.expires_at).toBeDefined(); + }); + + it('returns 409 for duplicate email', async () => { + await createTestUser({ email: 'dup@example.com' }); + + const res = await request(app).post('/v1/auth/signup').send({ + email: 'dup@example.com', + password: 'password123', + name: 'Dup User', + }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('CONFLICT'); + }); + + it('returns 400 for invalid email', async () => { + const res = await request(app).post('/v1/auth/signup').send({ + email: 'not-an-email', + password: 'password123', + name: 'User', + }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for short password', async () => { + const res = await request(app).post('/v1/auth/signup').send({ + email: 'user@example.com', + password: 'short', + name: 'User', + }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /v1/auth/signin', () => { + it('signs in with valid credentials', async () => { + await createTestUser({ email: 'signin@example.com', password: 'mypassword1' }); + + const res = await request(app).post('/v1/auth/signin').send({ + email: 'signin@example.com', + password: 'mypassword1', + }); + + expect(res.status).toBe(200); + expect(res.body.token).toBeDefined(); + }); + + it('returns 401 for wrong password', async () => { + await createTestUser({ email: 'wrong@example.com', password: 'correctpass1' }); + + const res = await request(app).post('/v1/auth/signin').send({ + email: 'wrong@example.com', + password: 'wrongpassword1', + }); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + }); + + it('returns 401 for non-existent user', async () => { + const res = await request(app).post('/v1/auth/signin').send({ + email: 'nobody@example.com', + password: 'password123', + }); + expect(res.status).toBe(401); + }); + + it('returns 403 for soft-deleted account', async () => { + const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + await createTestUser({ + email: 'deleted@example.com', + password: 'password123', + deleted_at: new Date().toISOString(), + deletion_scheduled_at: futureDate, + }); + + const res = await request(app).post('/v1/auth/signin').send({ + email: 'deleted@example.com', + password: 'password123', + }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('ACCOUNT_PENDING_DELETION'); + expect(res.body.deletion_scheduled_at).toBeDefined(); + }); +}); + +describe('DELETE /v1/auth/account', () => { + it('soft-deletes the authenticated user', async () => { + const { user } = await createTestUser(); + const { token } = signToken(user.id); + + const res = await request(app) + .delete('/v1/auth/account') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(204); + + const updated = await db('users').where({ id: user.id }).first(); + expect(updated.deleted_at).not.toBeNull(); + expect(updated.deletion_scheduled_at).not.toBeNull(); + }); + + it('returns 401 without token', async () => { + const res = await request(app).delete('/v1/auth/account'); + expect(res.status).toBe(401); + }); +}); + +describe('POST /v1/auth/restore-account', () => { + it('restores a soft-deleted account', async () => { + const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + const { user } = await createTestUser({ + deleted_at: new Date().toISOString(), + deletion_scheduled_at: futureDate, + }); + const { token } = signToken(user.id); + + // Temporarily bypass the auth middleware deleted_at check for restore + // by directly calling the endpoint (auth middleware allows restore-account path) + const res = await request(app) + .post('/v1/auth/restore-account') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.user.deleted_at).toBeNull(); + }); +}); + +describe('POST /v1/auth/password-reset', () => { + it('always returns 200 regardless of email existence', async () => { + const res = await request(app) + .post('/v1/auth/password-reset') + .send({ email: 'nonexistent@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('If an account exists'); + }); + + it('returns 400 for invalid email', async () => { + const res = await request(app) + .post('/v1/auth/password-reset') + .send({ email: 'not-valid' }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /health', () => { + it('returns 200 with status ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); +}); diff --git a/src/test/routes/pantry.test.ts b/src/test/routes/pantry.test.ts new file mode 100644 index 0000000..1c2dcc5 --- /dev/null +++ b/src/test/routes/pantry.test.ts @@ -0,0 +1,163 @@ +import request from 'supertest'; +import { createApp } from '../../app'; +import db from '../../db/connection'; +import { createTestUser, cleanupTestData } from '../helpers'; +import { signToken } from '../../utils/jwt'; + +const app = createApp(); + +let userId: string; +let token: string; + +beforeEach(async () => { + await cleanupTestData(); + const { user } = await createTestUser(); + userId = user.id; + token = signToken(userId).token; +}); + +afterAll(async () => { + await cleanupTestData(); + await db.destroy(); +}); + +describe('GET /v1/pantry', () => { + it('returns empty pantry for new user', async () => { + const res = await request(app) + .get('/v1/pantry') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.items).toEqual([]); + expect(res.body.synced_at).toBeDefined(); + }); + + it('returns 401 without token', async () => { + const res = await request(app).get('/v1/pantry'); + expect(res.status).toBe(401); + }); +}); + +describe('POST /v1/pantry', () => { + it('adds a new pantry item', async () => { + const res = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Flour', quantity: 5 }); + + expect(res.status).toBe(201); + expect(res.body.item.item_name).toBe('Flour'); + expect(res.body.item.quantity).toBe(5); + }); + + it('returns 409 for duplicate item (case-insensitive)', async () => { + await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Flour', quantity: 5 }); + + const res = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'flour', quantity: 3 }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('DUPLICATE_ITEM'); + }); + + it('returns 400 for invalid quantity', async () => { + const res = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Flour', quantity: 0 }); + + expect(res.status).toBe(400); + }); + + it('returns 400 for fractional quantity', async () => { + const res = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Flour', quantity: 1.5 }); + + expect(res.status).toBe(400); + }); +}); + +describe('PUT /v1/pantry/:item_id', () => { + it('updates pantry item quantity', async () => { + const createRes = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Butter', quantity: 2 }); + + const itemId = createRes.body.item.id; + + const res = await request(app) + .put(`/v1/pantry/${itemId}`) + .set('Authorization', `Bearer ${token}`) + .send({ quantity: 7 }); + + expect(res.status).toBe(200); + expect(res.body.item.quantity).toBe(7); + }); + + it('returns 404 for non-existent item', async () => { + const res = await request(app) + .put('/v1/pantry/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`) + .send({ quantity: 5 }); + + expect(res.status).toBe(404); + }); + + it('cannot update another user\'s item', async () => { + const { user: otherUser } = await createTestUser({ email: 'other@example.com' }); + const otherToken = signToken(otherUser.id).token; + + const createRes = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Sugar', quantity: 3 }); + + const itemId = createRes.body.item.id; + + const res = await request(app) + .put(`/v1/pantry/${itemId}`) + .set('Authorization', `Bearer ${otherToken}`) + .send({ quantity: 10 }); + + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /v1/pantry/:item_id', () => { + it('deletes a pantry item', async () => { + const createRes = await request(app) + .post('/v1/pantry') + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Salt', quantity: 1 }); + + const itemId = createRes.body.item.id; + + const res = await request(app) + .delete(`/v1/pantry/${itemId}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(204); + + // Verify tombstone created + const tombstone = await db('deleted_records') + .where({ record_id: itemId, table_name: 'pantry_items' }) + .first(); + expect(tombstone).toBeDefined(); + }); + + it('returns 404 for non-existent item', async () => { + const res = await request(app) + .delete('/v1/pantry/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(404); + }); +}); diff --git a/src/test/routes/recipes.test.ts b/src/test/routes/recipes.test.ts new file mode 100644 index 0000000..fda5c87 --- /dev/null +++ b/src/test/routes/recipes.test.ts @@ -0,0 +1,148 @@ +import request from 'supertest'; +import { createApp } from '../../app'; +import db from '../../db/connection'; +import { createTestUser, cleanupTestData } from '../helpers'; +import { signToken } from '../../utils/jwt'; + +const app = createApp(); + +let userId: string; +let token: string; + +beforeAll(async () => { + await cleanupTestData(); + // Seed a test recipe + const [recipe] = await db('recipes') + .insert({ + name: 'Test Cookies', + servings: 12, + instructions: 'Mix and bake.', + }) + .returning('*'); + + await db('recipe_ingredients').insert([ + { recipe_id: recipe.id, item_name: 'Flour', item_name_lower: 'flour', quantity: 2, unit: 'cups' }, + { recipe_id: recipe.id, item_name: 'Sugar', item_name_lower: 'sugar', quantity: 1, unit: 'cups' }, + { recipe_id: recipe.id, item_name: 'Butter', item_name_lower: 'butter', quantity: 0.5, unit: 'cups' }, + ]); +}); + +beforeEach(async () => { + await db('pantry_items').delete(); + await db('users').delete(); + const { user } = await createTestUser(); + userId = user.id; + token = signToken(userId).token; +}); + +afterAll(async () => { + await cleanupTestData(); + await db.destroy(); +}); + +describe('GET /v1/recipes', () => { + it('returns all recipes with pagination', async () => { + const res = await request(app) + .get('/v1/recipes') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.recipes)).toBe(true); + expect(res.body.pagination).toBeDefined(); + expect(res.body.synced_at).toBeDefined(); + }); + + it('returns 401 without token', async () => { + const res = await request(app).get('/v1/recipes'); + expect(res.status).toBe(401); + }); + + it('filters can_make correctly when pantry has all ingredients', async () => { + // Add all ingredients to pantry + await db('pantry_items').insert([ + { user_id: userId, item_name: 'Flour', item_name_lower: 'flour', quantity: 5 }, + { user_id: userId, item_name: 'Sugar', item_name_lower: 'sugar', quantity: 3 }, + { user_id: userId, item_name: 'Butter', item_name_lower: 'butter', quantity: 2 }, + ]); + + const res = await request(app) + .get('/v1/recipes?filter=can_make') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const testRecipe = res.body.recipes.find((r: { name: string }) => r.name === 'Test Cookies'); + expect(testRecipe).toBeDefined(); + expect(testRecipe.can_make).toBe(true); + }); + + it('filters can_partially_make correctly', async () => { + // Add only one ingredient + await db('pantry_items').insert([ + { user_id: userId, item_name: 'Flour', item_name_lower: 'flour', quantity: 5 }, + ]); + + const res = await request(app) + .get('/v1/recipes?filter=can_partially_make') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const testRecipe = res.body.recipes.find((r: { name: string }) => r.name === 'Test Cookies'); + expect(testRecipe).toBeDefined(); + expect(testRecipe.can_make).toBe(false); + }); + + it('returns 400 for invalid filter', async () => { + const res = await request(app) + .get('/v1/recipes?filter=invalid') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(400); + }); +}); + +describe('GET /v1/recipes/:recipe_id', () => { + it('returns recipe detail with scaling', async () => { + const recipe = await db('recipes').where({ name: 'Test Cookies' }).first(); + + const res = await request(app) + .get(`/v1/recipes/${recipe.id}?scale=2`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.recipe.scale_factor).toBe(2); + expect(res.body.recipe.scaled_servings).toBe(24); + const flourIng = res.body.recipe.ingredients.find((i: { item_name: string }) => i.item_name === 'Flour'); + expect(flourIng.quantity).toBe(4); // 2 * 2 + expect(flourIng.original_quantity).toBe(2); + }); + + it('returns 404 for non-existent recipe', async () => { + const res = await request(app) + .get('/v1/recipes/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid scale factor', async () => { + const recipe = await db('recipes').where({ name: 'Test Cookies' }).first(); + const res = await request(app) + .get(`/v1/recipes/${recipe.id}?scale=5`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(400); + }); + + it('marks in_pantry correctly', async () => { + await db('pantry_items').insert([ + { user_id: userId, item_name: 'Flour', item_name_lower: 'flour', quantity: 5 }, + ]); + + const recipe = await db('recipes').where({ name: 'Test Cookies' }).first(); + const res = await request(app) + .get(`/v1/recipes/${recipe.id}`) + .set('Authorization', `Bearer ${token}`); + + const flourIng = res.body.recipe.ingredients.find((i: { item_name: string }) => i.item_name === 'Flour'); + const sugarIng = res.body.recipe.ingredients.find((i: { item_name: string }) => i.item_name === 'Sugar'); + expect(flourIng.in_pantry).toBe(true); + expect(sugarIng.in_pantry).toBe(false); + }); +}); diff --git a/src/test/routes/shoppingLists.test.ts b/src/test/routes/shoppingLists.test.ts new file mode 100644 index 0000000..252371e --- /dev/null +++ b/src/test/routes/shoppingLists.test.ts @@ -0,0 +1,277 @@ +import request from 'supertest'; +import { createApp } from '../../app'; +import db from '../../db/connection'; +import { createTestUser, cleanupTestData } from '../helpers'; +import { signToken } from '../../utils/jwt'; + +const app = createApp(); + +let userId: string; +let token: string; + +beforeEach(async () => { + await cleanupTestData(); + const { user } = await createTestUser(); + userId = user.id; + token = signToken(userId).token; +}); + +afterAll(async () => { + await cleanupTestData(); + await db.destroy(); +}); + +async function createList(name = 'Test List') { + const res = await request(app) + .post('/v1/shopping-lists') + .set('Authorization', `Bearer ${token}`) + .send({ list_name: name }); + return res.body.shopping_list; +} + +describe('GET /v1/shopping-lists', () => { + it('returns empty list for new user', async () => { + const res = await request(app) + .get('/v1/shopping-lists') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.shopping_lists).toEqual([]); + }); +}); + +describe('POST /v1/shopping-lists', () => { + it('creates a new shopping list', async () => { + const res = await request(app) + .post('/v1/shopping-lists') + .set('Authorization', `Bearer ${token}`) + .send({ list_name: 'Groceries' }); + + expect(res.status).toBe(201); + expect(res.body.shopping_list.list_name).toBe('Groceries'); + expect(res.body.shopping_list.item_count).toBe(0); + }); + + it('returns 400 for empty list name', async () => { + const res = await request(app) + .post('/v1/shopping-lists') + .set('Authorization', `Bearer ${token}`) + .send({ list_name: '' }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /v1/shopping-lists/:list_id', () => { + it('returns list with items', async () => { + const list = await createList(); + + const res = await request(app) + .get(`/v1/shopping-lists/${list.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.shopping_list.id).toBe(list.id); + expect(res.body.shopping_list.items).toEqual([]); + }); + + it('returns 404 for non-existent list', async () => { + const res = await request(app) + .get('/v1/shopping-lists/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(404); + }); + + it('cannot access another user\'s list', async () => { + const { user: other } = await createTestUser({ email: 'other2@example.com' }); + const otherToken = signToken(other.id).token; + const list = await createList(); + + const res = await request(app) + .get(`/v1/shopping-lists/${list.id}`) + .set('Authorization', `Bearer ${otherToken}`); + expect(res.status).toBe(404); + }); +}); + +describe('POST /v1/shopping-lists/:list_id/items', () => { + it('adds an item to the list', async () => { + const list = await createList(); + + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Milk', quantity: 2, unit: 'cups' }); + + expect(res.status).toBe(201); + expect(res.body.item.item_name).toBe('Milk'); + expect(res.body.merged).toBe(false); + }); + + it('merges items with same name and unit', async () => { + const list = await createList(); + + await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Milk', quantity: 2, unit: 'cups' }); + + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'milk', quantity: 1, unit: 'cups' }); + + expect(res.status).toBe(201); + expect(res.body.merged).toBe(true); + expect(parseFloat(res.body.item.quantity)).toBe(3); + expect(res.body.previous_quantity).toBe(2); + }); + + it('does NOT merge items with same name but different unit', async () => { + const list = await createList(); + + await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Flour', quantity: 2, unit: 'cups' }); + + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Flour', quantity: 250, unit: 'g' }); + + expect(res.status).toBe(201); + expect(res.body.merged).toBe(false); + + const listRes = await request(app) + .get(`/v1/shopping-lists/${list.id}`) + .set('Authorization', `Bearer ${token}`); + expect(listRes.body.shopping_list.items.length).toBe(2); + }); + + it('returns 400 for invalid unit', async () => { + const list = await createList(); + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Milk', quantity: 2, unit: 'gallons' }); + expect(res.status).toBe(400); + }); +}); + +describe('PUT /v1/shopping-lists/:list_id/items/:item_id', () => { + it('updates item checked_off status', async () => { + const list = await createList(); + const addRes = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Eggs', quantity: 6, unit: 'pieces' }); + + const itemId = addRes.body.item.id; + + const res = await request(app) + .put(`/v1/shopping-lists/${list.id}/items/${itemId}`) + .set('Authorization', `Bearer ${token}`) + .send({ checked_off: true }); + + expect(res.status).toBe(200); + expect(res.body.item.checked_off).toBe(true); + }); + + it('returns 400 when no fields provided', async () => { + const list = await createList(); + const addRes = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Eggs', quantity: 6, unit: 'pieces' }); + + const itemId = addRes.body.item.id; + + const res = await request(app) + .put(`/v1/shopping-lists/${list.id}/items/${itemId}`) + .set('Authorization', `Bearer ${token}`) + .send({}); + + expect(res.status).toBe(400); + }); +}); + +describe('DELETE /v1/shopping-lists/:list_id', () => { + it('deletes a shopping list', async () => { + const list = await createList(); + + const res = await request(app) + .delete(`/v1/shopping-lists/${list.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(204); + }); + + it('returns 404 for non-existent list', async () => { + const res = await request(app) + .delete('/v1/shopping-lists/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /v1/shopping-lists/:list_id/items/:item_id', () => { + it('deletes an item from the list', async () => { + const list = await createList(); + const addRes = await request(app) + .post(`/v1/shopping-lists/${list.id}/items`) + .set('Authorization', `Bearer ${token}`) + .send({ item_name: 'Butter', quantity: 1, unit: 'cups' }); + + const itemId = addRes.body.item.id; + + const res = await request(app) + .delete(`/v1/shopping-lists/${list.id}/items/${itemId}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(204); + }); +}); + +describe('POST /v1/shopping-lists/:list_id/add-recipes', () => { + it('adds recipe ingredients to list', async () => { + const [recipe] = await db('recipes') + .insert({ name: 'Simple Recipe', servings: 2, instructions: 'Cook it.' }) + .returning('*'); + + await db('recipe_ingredients').insert([ + { recipe_id: recipe.id, item_name: 'Flour', item_name_lower: 'flour', quantity: 1, unit: 'cups' }, + { recipe_id: recipe.id, item_name: 'Water', item_name_lower: 'water', quantity: 0.5, unit: 'cups' }, + ]); + + const list = await createList(); + + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/add-recipes`) + .set('Authorization', `Bearer ${token}`) + .send({ recipe_ids: [recipe.id], scale_factor: 2 }); + + expect(res.status).toBe(201); + expect(res.body.recipes_added).toBe(1); + expect(res.body.items_created).toBe(2); + + const flourItem = res.body.shopping_list.items.find((i: { item_name: string }) => i.item_name === 'Flour'); + expect(parseFloat(flourItem.quantity)).toBe(2); // 1 * scale 2 + }); + + it('returns 400 for empty recipe_ids', async () => { + const list = await createList(); + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/add-recipes`) + .set('Authorization', `Bearer ${token}`) + .send({ recipe_ids: [] }); + expect(res.status).toBe(400); + }); + + it('returns 404 for non-existent recipe', async () => { + const list = await createList(); + const res = await request(app) + .post(`/v1/shopping-lists/${list.id}/add-recipes`) + .set('Authorization', `Bearer ${token}`) + .send({ recipe_ids: ['00000000-0000-0000-0000-000000000000'] }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/test/routes/sync.test.ts b/src/test/routes/sync.test.ts new file mode 100644 index 0000000..5a142f7 --- /dev/null +++ b/src/test/routes/sync.test.ts @@ -0,0 +1,87 @@ +import request from 'supertest'; +import { createApp } from '../../app'; +import db from '../../db/connection'; +import { createTestUser, cleanupTestData } from '../helpers'; +import { signToken } from '../../utils/jwt'; + +const app = createApp(); + +let userId: string; +let token: string; + +beforeEach(async () => { + await cleanupTestData(); + const { user } = await createTestUser(); + userId = user.id; + token = signToken(userId).token; +}); + +afterAll(async () => { + await cleanupTestData(); + await db.destroy(); +}); + +describe('GET /v1/sync', () => { + it('returns delta since epoch (full sync)', async () => { + // Add a pantry item + await db('pantry_items').insert({ + user_id: userId, + item_name: 'Flour', + item_name_lower: 'flour', + quantity: 5, + }); + + const res = await request(app) + .get('/v1/sync?since=1970-01-01T00:00:00.000Z') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.server_timestamp).toBeDefined(); + expect(res.body.pantry.updated.length).toBe(1); + expect(res.body.pantry.updated[0].item_name).toBe('Flour'); + expect(Array.isArray(res.body.pantry.deleted)).toBe(true); + expect(Array.isArray(res.body.shopping_lists.updated)).toBe(true); + expect(Array.isArray(res.body.shopping_lists.deleted)).toBe(true); + }); + + it('returns only items modified since timestamp', async () => { + const since = new Date().toISOString(); + + // Wait a tick then add item + await new Promise((r) => setTimeout(r, 10)); + + await db('pantry_items').insert({ + user_id: userId, + item_name: 'Sugar', + item_name_lower: 'sugar', + quantity: 2, + }); + + const res = await request(app) + .get(`/v1/sync?since=${encodeURIComponent(since)}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.pantry.updated.length).toBe(1); + expect(res.body.pantry.updated[0].item_name).toBe('Sugar'); + }); + + it('returns 400 for missing since parameter', async () => { + const res = await request(app) + .get('/v1/sync') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid since parameter', async () => { + const res = await request(app) + .get('/v1/sync?since=not-a-date') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(400); + }); + + it('returns 401 without token', async () => { + const res = await request(app).get('/v1/sync?since=1970-01-01T00:00:00.000Z'); + expect(res.status).toBe(401); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..0d23329 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,4 @@ +process.env.NODE_ENV = 'test'; +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test'; +process.env.JWT_SECRET = 'test_jwt_secret_that_is_long_enough_for_testing_purposes_only'; +process.env.GOOGLE_CLIENT_ID = 'test_google_client_id'; diff --git a/src/test/utils.test.ts b/src/test/utils.test.ts new file mode 100644 index 0000000..6c68805 --- /dev/null +++ b/src/test/utils.test.ts @@ -0,0 +1,27 @@ +import { createError } from '../../middleware/errorHandler'; +import { signToken } from '../../utils/jwt'; +import jwt from 'jsonwebtoken'; + +describe('createError', () => { + it('creates an error with statusCode and code', () => { + const err = createError('Something went wrong', 404, 'NOT_FOUND'); + expect(err.message).toBe('Something went wrong'); + expect(err.statusCode).toBe(404); + expect(err.code).toBe('NOT_FOUND'); + }); +}); + +describe('signToken', () => { + it('returns a token and expiry date', () => { + const { token, expiresAt } = signToken('test-user-id'); + expect(typeof token).toBe('string'); + expect(expiresAt).toBeInstanceOf(Date); + expect(expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it('encodes userId in the token', () => { + const { token } = signToken('my-user-id'); + const decoded = jwt.decode(token) as { userId: string }; + expect(decoded.userId).toBe('my-user-id'); + }); +}); diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts new file mode 100644 index 0000000..b6bde53 --- /dev/null +++ b/src/test/validators.test.ts @@ -0,0 +1,158 @@ +import { signupSchema, signinSchema, addPantryItemSchema, updatePantryItemSchema, addShoppingListItemSchema, updateShoppingListItemSchema, addRecipesToListSchema, recipeQuerySchema, syncQuerySchema } from '../../utils/validators'; + +describe('Validators', () => { + describe('signupSchema', () => { + it('accepts valid signup data', () => { + const result = signupSchema.safeParse({ email: 'user@example.com', password: 'password123', name: 'Jane' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid email', () => { + const result = signupSchema.safeParse({ email: 'not-an-email', password: 'password123', name: 'Jane' }); + expect(result.success).toBe(false); + }); + + it('rejects short password', () => { + const result = signupSchema.safeParse({ email: 'user@example.com', password: 'short', name: 'Jane' }); + expect(result.success).toBe(false); + }); + + it('rejects non-alphanumeric password', () => { + const result = signupSchema.safeParse({ email: 'user@example.com', password: 'pass!word', name: 'Jane' }); + expect(result.success).toBe(false); + }); + + it('rejects empty name', () => { + const result = signupSchema.safeParse({ email: 'user@example.com', password: 'password123', name: '' }); + expect(result.success).toBe(false); + }); + }); + + describe('addPantryItemSchema', () => { + it('accepts valid pantry item', () => { + const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: 5 }); + expect(result.success).toBe(true); + }); + + it('rejects zero quantity', () => { + const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects negative quantity', () => { + const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects fractional quantity', () => { + const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: 1.5 }); + expect(result.success).toBe(false); + }); + + it('rejects empty item name', () => { + const result = addPantryItemSchema.safeParse({ item_name: '', quantity: 5 }); + expect(result.success).toBe(false); + }); + }); + + describe('updatePantryItemSchema', () => { + it('accepts valid update', () => { + const result = updatePantryItemSchema.safeParse({ quantity: 3 }); + expect(result.success).toBe(true); + }); + + it('accepts update with last_modified', () => { + const result = updatePantryItemSchema.safeParse({ quantity: 3, last_modified: '2024-01-15T10:30:00.000Z' }); + expect(result.success).toBe(true); + }); + }); + + describe('addShoppingListItemSchema', () => { + it('accepts valid item', () => { + const result = addShoppingListItemSchema.safeParse({ item_name: 'Milk', quantity: 2, unit: 'cups' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid unit', () => { + const result = addShoppingListItemSchema.safeParse({ item_name: 'Milk', quantity: 2, unit: 'gallons' }); + expect(result.success).toBe(false); + }); + + it('accepts fractional quantity', () => { + const result = addShoppingListItemSchema.safeParse({ item_name: 'Flour', quantity: 2.5, unit: 'cups' }); + expect(result.success).toBe(true); + }); + }); + + describe('updateShoppingListItemSchema', () => { + it('accepts partial update', () => { + const result = updateShoppingListItemSchema.safeParse({ checked_off: true }); + expect(result.success).toBe(true); + }); + + it('rejects empty object', () => { + const result = updateShoppingListItemSchema.safeParse({}); + expect(result.success).toBe(false); + }); + }); + + describe('addRecipesToListSchema', () => { + it('accepts valid recipe ids', () => { + const result = addRecipesToListSchema.safeParse({ + recipe_ids: ['123e4567-e89b-12d3-a456-426614174000'], + scale_factor: 2, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty recipe_ids', () => { + const result = addRecipesToListSchema.safeParse({ recipe_ids: [] }); + expect(result.success).toBe(false); + }); + + it('rejects scale_factor > 3', () => { + const result = addRecipesToListSchema.safeParse({ + recipe_ids: ['123e4567-e89b-12d3-a456-426614174000'], + scale_factor: 4, + }); + expect(result.success).toBe(false); + }); + + it('defaults scale_factor to 1', () => { + const result = addRecipesToListSchema.safeParse({ + recipe_ids: ['123e4567-e89b-12d3-a456-426614174000'], + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.scale_factor).toBe(1); + }); + }); + + describe('recipeQuerySchema', () => { + it('defaults to all filter, page 1, limit 20', () => { + const result = recipeQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.filter).toBe('all'); + expect(result.data.page).toBe(1); + expect(result.data.limit).toBe(20); + } + }); + + it('rejects limit > 50', () => { + const result = recipeQuerySchema.safeParse({ limit: '51' }); + expect(result.success).toBe(false); + }); + }); + + describe('syncQuerySchema', () => { + it('accepts valid ISO timestamp', () => { + const result = syncQuerySchema.safeParse({ since: '2024-01-15T10:30:00.000Z' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid timestamp', () => { + const result = syncQuerySchema.safeParse({ since: 'not-a-date' }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000..6c7902d --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,17 @@ +import jwt from 'jsonwebtoken'; +import { config } from '../config/env'; + +export interface TokenPayload { + userId: string; +} + +export function signToken(userId: string): { token: string; expiresAt: Date } { + const expiresIn = config.jwtExpiresIn; + const token = jwt.sign({ userId }, config.jwtSecret, { expiresIn } as jwt.SignOptions); + + // Calculate expiry date + const decoded = jwt.decode(token) as { exp: number }; + const expiresAt = new Date(decoded.exp * 1000); + + return { token, expiresAt }; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..3afedc9 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,15 @@ +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { config } from '../config/env'; + +export const logger = pino({ + level: config.isTest ? 'silent' : 'info', + transport: config.isDev + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +export const httpLogger = pinoHttp({ + logger, + autoLogging: !config.isTest, +}); diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..4624a16 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; +import { ALLOWED_UNITS } from '../config/constants'; + +export const signupSchema = z.object({ + email: z.string().email('Invalid email format.'), + password: z + .string() + .min(8, 'Password must be at least 8 characters.') + .regex(/^[a-zA-Z0-9]+$/, 'Password must be alphanumeric.'), + name: z.string().min(1, 'Name is required.').max(255), +}); + +export const signinSchema = z.object({ + email: z.string().email('Invalid email format.'), + password: z.string().min(1, 'Password is required.'), +}); + +export const googleAuthSchema = z.object({ + id_token: z.string().min(1, 'id_token is required.'), +}); + +export const passwordResetRequestSchema = z.object({ + email: z.string().email('Invalid email format.'), +}); + +export const passwordResetConfirmSchema = z.object({ + token: z.string().min(1, 'Token is required.'), + new_password: z + .string() + .min(8, 'Password must be at least 8 characters.') + .regex(/^[a-zA-Z0-9]+$/, 'Password must be alphanumeric.'), +}); + +export const addPantryItemSchema = z.object({ + item_name: z.string().min(1, 'Item name is required.').max(255), + quantity: z + .number() + .int('Quantity must be a whole number.') + .positive('Quantity must be positive.'), +}); + +export const updatePantryItemSchema = z.object({ + quantity: z + .number() + .int('Quantity must be a whole number.') + .positive('Quantity must be positive.'), + last_modified: z.string().datetime().optional(), +}); + +export const createShoppingListSchema = z.object({ + list_name: z.string().min(1, 'List name is required.').max(255), +}); + +export const addShoppingListItemSchema = z.object({ + item_name: z.string().min(1, 'Item name is required.').max(255), + quantity: z.number().positive('Quantity must be positive.'), + unit: z.enum(ALLOWED_UNITS as [string, ...string[]], { + errorMap: () => ({ message: `Unit must be one of: ${ALLOWED_UNITS.join(', ')}` }), + }), +}); + +export const updateShoppingListItemSchema = z + .object({ + quantity: z.number().positive('Quantity must be positive.').optional(), + unit: z + .enum(ALLOWED_UNITS as [string, ...string[]], { + errorMap: () => ({ message: `Unit must be one of: ${ALLOWED_UNITS.join(', ')}` }), + }) + .optional(), + checked_off: z.boolean().optional(), + }) + .refine( + (data) => Object.keys(data).length > 0, + { message: 'At least one field must be provided.' } + ); + +export const addRecipesToListSchema = z.object({ + recipe_ids: z + .array(z.string().uuid('Each recipe_id must be a valid UUID.')) + .min(1, 'At least one recipe_id is required.'), + scale_factor: z.number().int().min(1).max(3).optional().default(1), +}); + +export const recipeQuerySchema = z.object({ + filter: z.enum(['all', 'can_make', 'can_partially_make']).optional().default('all'), + page: z.coerce.number().int().positive().optional().default(1), + limit: z.coerce.number().int().positive().max(50).optional().default(20), + search: z.string().optional(), +}); + +export const recipeDetailQuerySchema = z.object({ + scale: z.coerce.number().int().min(1).max(3).optional().default(1), +}); + +export const syncQuerySchema = z.object({ + since: z.string().datetime('since must be a valid ISO 8601 timestamp.'), +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bb5605d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}