feat: implement full backend API for Pantree Phase 1 MVP
This commit is contained in:
112
src/services/pantryService.js
Normal file
112
src/services/pantryService.js
Normal file
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
const { getDb } = require('../db/knex');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
function formatItem(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
item_name: row.item_name,
|
||||
quantity: row.quantity,
|
||||
last_modified: row.last_modified,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all pantry items for the authenticated user, ordered alphabetically.
|
||||
*/
|
||||
async function getPantryItems(userId) {
|
||||
const db = getDb();
|
||||
const items = await db('pantry_items')
|
||||
.where({ user_id: userId })
|
||||
.orderBy('item_name_lower', 'asc');
|
||||
return items.map(formatItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new pantry item.
|
||||
* Rejects with 409 if an item with the same name already exists (case-insensitive).
|
||||
* Auto-merge is intentionally not performed — explicit > implicit.
|
||||
*/
|
||||
async function addPantryItem(userId, { item_name, quantity }) {
|
||||
const db = getDb();
|
||||
const trimmedName = item_name.trim();
|
||||
|
||||
const existing = await db('pantry_items')
|
||||
.where({ user_id: userId })
|
||||
.whereRaw('item_name_lower = LOWER(?)', [trimmedName])
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'DUPLICATE_ITEM',
|
||||
`'${existing.item_name}' already exists in your pantry.`,
|
||||
{ existing_item: formatItem(existing) }
|
||||
);
|
||||
}
|
||||
|
||||
const [item] = await db('pantry_items')
|
||||
.insert({ user_id: userId, item_name: trimmedName, quantity })
|
||||
.returning('*');
|
||||
|
||||
return formatItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the quantity of an existing pantry item.
|
||||
* Server sets last_modified = NOW() — server clock is authoritative.
|
||||
* Verifies ownership: item must belong to the requesting user.
|
||||
*/
|
||||
async function updatePantryItem(userId, itemId, { quantity }) {
|
||||
const db = getDb();
|
||||
|
||||
const existing = await db('pantry_items')
|
||||
.where({ id: itemId, user_id: userId })
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Pantry item not found.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [item] = await db('pantry_items')
|
||||
.where({ id: itemId, user_id: userId })
|
||||
.update({ quantity, last_modified: now, updated_at: now })
|
||||
.returning('*');
|
||||
|
||||
return formatItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a pantry item and records a tombstone for sync.
|
||||
* Verifies ownership before deletion.
|
||||
*/
|
||||
async function deletePantryItem(userId, itemId) {
|
||||
const db = getDb();
|
||||
|
||||
const existing = await db('pantry_items')
|
||||
.where({ id: itemId, user_id: userId })
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Pantry item not found.');
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('pantry_items').where({ id: itemId, user_id: userId }).delete();
|
||||
await trx('deleted_records').insert({
|
||||
user_id: userId,
|
||||
record_type: 'pantry_item',
|
||||
record_id: itemId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPantryItems,
|
||||
addPantryItem,
|
||||
updatePantryItem,
|
||||
deletePantryItem,
|
||||
};
|
||||
Reference in New Issue
Block a user