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:
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');
|
||||
}
|
||||
Reference in New Issue
Block a user