2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
2026-05-09 13:03:36 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
2026-05-03 19:51:57 -05:00
const router = express . Router ( ) ;
const { getDb , ensureUserDefaultCategories } = require ( '../db/database' ) ;
// GET /api/categories
router . get ( '/' , ( req , res ) => {
const db = getDb ( ) ;
ensureUserDefaultCategories ( req . user . id ) ;
2026-05-04 16:38:03 -05:00
const categories = db . prepare ( `
2026-05-30 20:04:50 -05:00
SELECT id , user _id , name , sort _order , created _at , updated _at
2026-05-04 16:38:03 -05:00
FROM categories
WHERE user _id = ?
2026-05-16 10:34:32 -05:00
AND deleted _at IS NULL
2026-05-30 20:04:50 -05:00
ORDER BY CASE WHEN sort _order IS NULL THEN 1 ELSE 0 END ,
sort _order ASC ,
name COLLATE NOCASE ASC
2026-05-04 16:38:03 -05:00
` ).all(req.user.id);
const billsByCategory = db . prepare ( `
SELECT
b . id ,
b . category _id ,
b . name ,
b . active ,
b . expected _amount ,
b . due _day ,
COUNT ( p . id ) AS payment _count ,
COALESCE ( SUM ( p . amount ) , 0 ) AS total _paid ,
MAX ( p . paid _date ) AS last _paid _date
FROM bills b
LEFT JOIN payments p
ON p . bill _id = b . id
AND p . deleted _at IS NULL
WHERE b . user _id = ?
AND b . category _id = ?
2026-05-16 10:34:32 -05:00
AND b . deleted _at IS NULL
2026-05-04 16:38:03 -05:00
GROUP BY b . id
ORDER BY b . active DESC , b . due _day ASC , b . name COLLATE NOCASE ASC
` );
const shaped = categories . map ( category => {
const bills = billsByCategory . all ( req . user . id , category . id ) . map ( bill => ( {
... bill ,
active : ! ! bill . active ,
payment _count : Number ( bill . payment _count || 0 ) ,
total _paid : Number ( bill . total _paid || 0 ) ,
last _paid _date : bill . last _paid _date || null ,
} ) ) ;
const activeBillCount = bills . filter ( bill => bill . active ) . length ;
const inactiveBillCount = bills . length - activeBillCount ;
const paymentCount = bills . reduce ( ( sum , bill ) => sum + bill . payment _count , 0 ) ;
return {
... category ,
bill _count : activeBillCount ,
active _bill _count : activeBillCount ,
inactive _bill _count : inactiveBillCount ,
payment _count : paymentCount ,
bill _names : bills . map ( bill => bill . name ) ,
bills ,
} ;
} ) ;
res . json ( shaped ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-05-30 20:04:50 -05:00
// PUT /api/categories/reorder
router . put ( '/reorder' , ( req , res ) => {
const db = getDb ( ) ;
const entries = Object . entries ( req . body || { } ) . map ( ( [ categoryId , sortOrder ] ) => ( {
categoryId : Number ( categoryId ) ,
sortOrder : Number ( sortOrder ) ,
} ) ) ;
if ( entries . length === 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'At least one category order is required' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
}
const invalid = entries . find ( ( { categoryId , sortOrder } ) => (
! Number . isInteger ( categoryId ) || categoryId <= 0 || ! Number . isInteger ( sortOrder ) || sortOrder < 0
) ) ;
if ( invalid ) {
return res . status ( 400 ) . json ( standardizeError ( 'Reorder payload must map category ids to non-negative integer positions' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
}
const ids = entries . map ( item => item . categoryId ) ;
const placeholders = ids . map ( ( ) => '?' ) . join ( ',' ) ;
const owned = db . prepare ( `
SELECT id
FROM categories
WHERE user _id = ? AND deleted _at IS NULL AND id IN ( $ { placeholders } )
` ).all(req.user.id, ...ids);
if ( owned . length !== ids . length ) {
return res . status ( 404 ) . json ( standardizeError ( 'One or more categories were not found' , 'NOT_FOUND' , 'category_id' ) ) ;
}
const update = db . prepare ( "UPDATE categories SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ) ;
db . transaction ( ( items ) => {
for ( const item of items ) update . run ( item . sortOrder , item . categoryId , req . user . id ) ;
} ) ( entries ) ;
res . json ( { success : true } ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// POST /api/categories
router . post ( '/' , ( req , res ) => {
const db = getDb ( ) ;
const { name } = req . body ;
2026-05-09 13:03:36 -05:00
if ( ! name ) return res . status ( 400 ) . json ( standardizeError ( 'name is required' , 'VALIDATION_ERROR' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
try {
const result = db . prepare ( 'INSERT INTO categories (user_id, name) VALUES (?, ?)' ) . run ( req . user . id , name . trim ( ) ) ;
const created = db . prepare ( 'SELECT * FROM categories WHERE id = ?' ) . get ( result . lastInsertRowid ) ;
res . status ( 201 ) . json ( created ) ;
} catch ( e ) {
if ( e . message . includes ( 'UNIQUE' ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 409 ) . json ( standardizeError ( 'Category already exists' , 'CONFLICT' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
}
throw e ;
}
} ) ;
// PUT /api/categories/:id
router . put ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
const { name } = req . body ;
2026-05-09 13:03:36 -05:00
if ( ! name ) return res . status ( 400 ) . json ( standardizeError ( 'name is required' , 'VALIDATION_ERROR' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-16 10:34:32 -05:00
const cat = db . prepare ( 'SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Category not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-03 19:51:57 -05:00
try {
db . prepare ( "UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" )
. run ( name . trim ( ) , req . params . id , req . user . id ) ;
2026-05-16 10:34:32 -05:00
res . json ( db . prepare ( 'SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ) ;
2026-05-03 19:51:57 -05:00
} catch ( e ) {
if ( e . message . includes ( 'UNIQUE' ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 409 ) . json ( standardizeError ( 'Category already exists' , 'CONFLICT' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
}
throw e ;
}
} ) ;
// DELETE /api/categories/:id
router . delete ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
const cat = db . prepare ( 'SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Category not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-04 20:12:57 -05:00
2026-05-16 10:34:32 -05:00
const deleted = db . prepare ( "UPDATE categories SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL" )
. run ( req . params . id , req . user . id ) ;
2026-05-04 20:12:57 -05:00
2026-05-16 10:34:32 -05:00
res . json ( {
success : true ,
deleted : deleted . changes ,
deleted _category _id : cat . id ,
deleted _category _name : cat . name ,
recoverable _until _days : 30 ,
2026-05-04 20:12:57 -05:00
} ) ;
2026-05-16 10:34:32 -05:00
} ) ;
// POST /api/categories/:id/restore — undo category soft delete
router . post ( '/:id/restore' , ( req , res ) => {
const db = getDb ( ) ;
const cat = db . prepare ( 'SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) . get ( req . params . id , req . user . id ) ;
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Deleted category not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-04 20:12:57 -05:00
2026-05-16 10:34:32 -05:00
db . prepare ( "UPDATE categories SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?" )
. run ( req . params . id , req . user . id ) ;
res . json ( db . prepare ( 'SELECT * FROM categories WHERE id = ? AND user_id = ?' ) . get ( req . params . id , req . user . id ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
module . exports = router ;