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