Initial commit: Fast Inventory app

Monorepo with Express + SQLite backend and Vite + React frontend.
Features: item CRUD, file uploads with thumbnails, soft delete,
item duplication with file copying, autocomplete inputs, stats dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 07:06:52 +01:00
commit 88e151f792
35 changed files with 8494 additions and 0 deletions

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"multer": "^1.4.5-lts.2",
"sharp": "^0.34.5",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0"
}
}

156
server/src/db.ts Normal file
View File

@@ -0,0 +1,156 @@
import Database, { type Database as DatabaseType } from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, '..', 'data');
const DB_PATH = join(DATA_DIR, 'inventory.db');
mkdirSync(DATA_DIR, { recursive: true });
const db: DatabaseType = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// --- Schema: initial tables ---
db.exec(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT DEFAULT '',
location TEXT DEFAULT '',
purchase_date TEXT DEFAULT '',
purchase_price REAL DEFAULT NULL,
status TEXT DEFAULT 'active'
CHECK(status IN ('active','stored','lent_out','sold','lost','trashed')),
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
CREATE INDEX IF NOT EXISTS idx_items_location ON items(location);
CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at);
CREATE TABLE IF NOT EXISTS item_files (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
file_type TEXT NOT NULL CHECK(file_type IN ('photo','receipt')),
filename TEXT NOT NULL,
stored_name TEXT NOT NULL,
mime_type TEXT DEFAULT '',
size_bytes INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_item_files_item_id ON item_files(item_id);
`);
// --- Versioned migrations ---
// Each migration runs exactly once, tracked by schema_version.
// NEVER remove or reorder existing migrations. Only append new ones.
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
);
`);
function getSchemaVersion(): number {
const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get() as { v: number | null };
return row.v ?? 0;
}
function runMigration(version: number, description: string, fn: () => void) {
if (getSchemaVersion() >= version) return;
console.log(`Running migration ${version}: ${description}`);
db.transaction(() => {
fn();
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
})();
}
// For migrations that recreate tables referenced by foreign keys,
// foreign_keys must be OFF to avoid CASCADE deletes on DROP TABLE.
function runMigrationUnsafe(version: number, description: string, fn: () => void) {
if (getSchemaVersion() >= version) return;
console.log(`Running migration ${version}: ${description}`);
db.pragma('foreign_keys = OFF');
db.transaction(() => {
fn();
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
})();
db.pragma('foreign_keys = ON');
}
// Migration 1: Add label_id auto-incrementing column
runMigration(1, 'Add label_id column', () => {
const columns = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
if (columns.some(c => c.name === 'label_id')) return; // already in CREATE TABLE for fresh DBs
db.exec("ALTER TABLE items ADD COLUMN label_id INTEGER");
db.exec(`
UPDATE items SET label_id = (
SELECT COUNT(*) FROM items AS i2
WHERE i2.created_at <= items.created_at AND i2.id <= items.id
)
`);
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_items_label_id ON items(label_id)");
});
// Migration 2: Add is_featured column to item_files
runMigration(2, 'Add is_featured column to item_files', () => {
const columns = db.prepare("PRAGMA table_info(item_files)").all() as { name: string }[];
if (columns.some(c => c.name === 'is_featured')) return;
db.exec("ALTER TABLE item_files ADD COLUMN is_featured INTEGER DEFAULT 0");
});
// Migration 3: Add deleted_at column for soft deletes
runMigration(3, 'Add deleted_at column to items', () => {
const columns = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
if (columns.some(c => c.name === 'deleted_at')) return;
db.exec("ALTER TABLE items ADD COLUMN deleted_at TEXT DEFAULT NULL");
});
// Migration 4: Add 'stored' status to items CHECK constraint
runMigrationUnsafe(4, 'Add stored status', () => {
// SQLite can't ALTER CHECK constraints, so we recreate the table
db.exec(`
CREATE TABLE items_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT DEFAULT '',
location TEXT DEFAULT '',
purchase_date TEXT DEFAULT '',
purchase_price REAL DEFAULT NULL,
status TEXT DEFAULT 'active'
CHECK(status IN ('active','stored','lent_out','sold','lost','trashed')),
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
label_id INTEGER,
deleted_at TEXT DEFAULT NULL
);
INSERT INTO items_new SELECT id, name, category, location, purchase_date, purchase_price, status, notes, created_at, updated_at, label_id, deleted_at FROM items;
DROP TABLE items;
ALTER TABLE items_new RENAME TO items;
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
CREATE INDEX IF NOT EXISTS idx_items_location ON items(location);
CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_label_id ON items(label_id);
`);
});
// Migration 5: Add flagged column to items
runMigration(5, 'Add flagged column to items', () => {
const columns = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
if (columns.some(c => c.name === 'flagged')) return;
db.exec("ALTER TABLE items ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0");
});
export default db;

41
server/src/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import express from 'express';
import cors from 'cors';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import itemsRouter from './routes/items.js';
import uploadsRouter, { createFileUploadRouter, createFileDeleteRouter } from './routes/uploads.js';
import statsRouter from './routes/stats.js';
import suggestionsRouter from './routes/suggestions.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// API routes
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/items', itemsRouter);
app.use('/api/items', createFileUploadRouter());
app.use('/api/files', createFileDeleteRouter());
app.use('/api/uploads', uploadsRouter);
app.use('/api/stats', statsRouter);
app.use('/api/suggestions', suggestionsRouter);
// Serve static client in production
const clientDist = join(__dirname, '..', '..', 'client', 'dist');
if (existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get('/{*path}', (_req, res) => {
res.sendFile(join(clientDist, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,37 @@
import multer from 'multer';
import { mkdirSync } from 'fs';
import { join, dirname, extname } from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuid } from 'uuid';
const __dirname = dirname(fileURLToPath(import.meta.url));
const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads');
mkdirSync(UPLOADS_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, file, cb) => {
const ext = extname(file.originalname);
cb(null, `${uuid()}${ext}`);
},
});
const ALLOWED_MIMES = [
'image/jpeg', 'image/png', 'image/webp', 'image/heic',
'application/pdf',
];
export const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (_req, file, cb) => {
if (ALLOWED_MIMES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type ${file.mimetype} not allowed`));
}
},
});
export { UPLOADS_DIR };

42
server/src/resize.ts Normal file
View File

@@ -0,0 +1,42 @@
import sharp from 'sharp';
import { join, parse } from 'path';
import { UPLOADS_DIR } from './middleware/upload.js';
interface VariantSpec {
suffix: string;
maxDimension: number;
}
const VARIANTS: VariantSpec[] = [
{ suffix: '_thumb', maxDimension: 300 },
{ suffix: '_medium', maxDimension: 768 },
{ suffix: '_large', maxDimension: 1920 },
];
const RESIZABLE_MIMES = new Set([
'image/jpeg', 'image/png', 'image/webp', 'image/heic',
]);
export function isResizable(mimeType: string): boolean {
return RESIZABLE_MIMES.has(mimeType);
}
export async function generateVariants(storedName: string): Promise<void> {
const { name, ext } = parse(storedName);
const sourcePath = join(UPLOADS_DIR, storedName);
for (const variant of VARIANTS) {
const outPath = join(UPLOADS_DIR, `${name}${variant.suffix}${ext}`);
await sharp(sourcePath)
.resize(variant.maxDimension, variant.maxDimension, {
fit: 'inside',
withoutEnlargement: true,
})
.toFile(outPath);
}
}
export function getVariantFilenames(storedName: string): string[] {
const { name, ext } = parse(storedName);
return VARIANTS.map(v => `${name}${v.suffix}${ext}`);
}

276
server/src/routes/items.ts Normal file
View File

@@ -0,0 +1,276 @@
import { Router } from 'express';
import { v4 as uuid } from 'uuid';
import { copyFileSync, unlinkSync } from 'fs';
import { join, parse } from 'path';
import db from '../db.js';
import { isResizable, generateVariants, getVariantFilenames } from '../resize.js';
import { UPLOADS_DIR } from '../middleware/upload.js';
import type { Item, ItemFile, CreateItemBody, UpdateItemBody } from '../types.js';
const router = Router();
// Bulk update items
router.patch('/bulk', (req, res) => {
const { ids, updates } = req.body as { ids: string[]; updates: { category?: string; location?: string; status?: string; flagged?: number } };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids array is required' });
return;
}
const fields: string[] = [];
const params: unknown[] = [];
if (updates.category !== undefined) { fields.push('category = ?'); params.push(updates.category.trim()); }
if (updates.location !== undefined) { fields.push('location = ?'); params.push(updates.location.trim()); }
if (updates.status !== undefined) { fields.push('status = ?'); params.push(updates.status); }
if (updates.flagged !== undefined) { fields.push('flagged = ?'); params.push(updates.flagged); }
if (fields.length === 0) {
res.json({ updated: 0 });
return;
}
fields.push("updated_at = datetime('now')");
const placeholders = ids.map(() => '?').join(', ');
const sql = `UPDATE items SET ${fields.join(', ')} WHERE id IN (${placeholders}) AND deleted_at IS NULL`;
const result = db.transaction(() => {
return db.prepare(sql).run(...params, ...ids);
})();
res.json({ updated: result.changes });
});
// List items with optional filters
router.get('/', (req, res) => {
const { status, category, location, search, sort = 'created_at', order = 'desc', limit = '50', offset = '0', deleted, flagged } = req.query as Record<string, string>;
let sql: string;
const params: unknown[] = [];
if (deleted === '1') {
// Show only soft-deleted items
sql = `SELECT items.*, (
SELECT stored_name FROM item_files
WHERE item_files.item_id = items.id AND item_files.file_type = 'photo'
ORDER BY is_featured DESC, created_at ASC LIMIT 1
) as thumbnail FROM items WHERE deleted_at IS NOT NULL`;
} else {
// Normal: exclude soft-deleted items
sql = `SELECT items.*, (
SELECT stored_name FROM item_files
WHERE item_files.item_id = items.id AND item_files.file_type = 'photo'
ORDER BY is_featured DESC, created_at ASC LIMIT 1
) as thumbnail FROM items WHERE deleted_at IS NULL`;
}
if (status) {
sql += ' AND status = ?';
params.push(status);
}
if (category) {
sql += ' AND category = ?';
params.push(category);
}
if (location) {
sql += ' AND location = ?';
params.push(location);
}
if (search) {
sql += ' AND (name LIKE ? OR notes LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
if (flagged === '1') {
sql += ' AND flagged = 1';
}
const allowedSorts = ['label_id', 'name', 'category', 'location', 'purchase_price', 'purchase_date', 'status', 'created_at', 'updated_at', 'deleted_at', 'flagged'];
const sortCol = allowedSorts.includes(sort) ? sort : 'created_at';
const sortOrder = order === 'asc' ? 'ASC' : 'DESC';
sql += ` ORDER BY ${sortCol} ${sortOrder}`;
sql += ' LIMIT ? OFFSET ?';
params.push(Number(limit), Number(offset));
const items = db.prepare(sql).all(...params) as Item[];
res.json(items);
});
// Get single item with files
router.get('/:id', (req, res) => {
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!item) {
res.status(404).json({ error: 'Item not found' });
return;
}
const files = db.prepare('SELECT * FROM item_files WHERE item_id = ? ORDER BY created_at DESC').all(req.params.id);
res.json({ ...item, files });
});
// Create item
router.post('/', (req, res) => {
const body = req.body as CreateItemBody;
if (!body.name?.trim()) {
res.status(400).json({ error: 'Name is required' });
return;
}
const id = uuid();
const { nextId } = db.prepare('SELECT COALESCE(MAX(label_id), 0) + 1 AS nextId FROM items').get() as { nextId: number };
const stmt = db.prepare(`
INSERT INTO items (id, label_id, name, category, location, purchase_date, purchase_price, status, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
id,
nextId,
body.name.trim(),
body.category?.trim() || '',
body.location?.trim() || '',
body.purchase_date || '',
body.purchase_price ?? null,
body.status || 'active',
body.notes?.trim() || '',
);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id) as Item;
res.status(201).json(item);
});
// Duplicate item (with files)
router.post('/:id/duplicate', async (req, res) => {
const source = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!source) {
res.status(404).json({ error: 'Item not found' });
return;
}
const newId = uuid();
const { nextId } = db.prepare('SELECT COALESCE(MAX(label_id), 0) + 1 AS nextId FROM items').get() as { nextId: number };
db.prepare(`
INSERT INTO items (id, label_id, name, category, location, purchase_date, purchase_price, status, notes, flagged)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newId,
nextId,
`Copy of ${source.name}`,
source.category,
source.location,
source.purchase_date,
source.purchase_price,
source.status,
source.notes,
source.flagged,
);
// Copy files
const sourceFiles = db.prepare('SELECT * FROM item_files WHERE item_id = ?').all(req.params.id) as ItemFile[];
const insertFile = db.prepare(`
INSERT INTO item_files (id, item_id, file_type, filename, stored_name, mime_type, size_bytes, is_featured)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const file of sourceFiles) {
const fileId = uuid();
const { ext } = parse(file.stored_name);
const newStoredName = `${uuid()}${ext}`;
try {
copyFileSync(join(UPLOADS_DIR, file.stored_name), join(UPLOADS_DIR, newStoredName));
} catch {
continue; // skip if source file is missing
}
insertFile.run(fileId, newId, file.file_type, file.filename, newStoredName, file.mime_type, file.size_bytes, file.is_featured);
if (isResizable(file.mime_type)) {
await generateVariants(newStoredName);
}
}
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(newId) as Item;
res.status(201).json(item);
});
// Update item
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found' });
return;
}
const body = req.body as UpdateItemBody;
const fields: string[] = [];
const params: unknown[] = [];
if (body.name !== undefined) { fields.push('name = ?'); params.push(body.name.trim()); }
if (body.category !== undefined) { fields.push('category = ?'); params.push(body.category.trim()); }
if (body.location !== undefined) { fields.push('location = ?'); params.push(body.location.trim()); }
if (body.purchase_date !== undefined) { fields.push('purchase_date = ?'); params.push(body.purchase_date); }
if (body.purchase_price !== undefined) { fields.push('purchase_price = ?'); params.push(body.purchase_price); }
if (body.status !== undefined) { fields.push('status = ?'); params.push(body.status); }
if (body.notes !== undefined) { fields.push('notes = ?'); params.push(body.notes.trim()); }
if (body.flagged !== undefined) { fields.push('flagged = ?'); params.push(body.flagged); }
if (fields.length === 0) {
res.json(existing);
return;
}
fields.push("updated_at = datetime('now')");
params.push(req.params.id);
db.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...params);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item;
res.json(item);
});
// Soft delete item
router.delete('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found' });
return;
}
db.prepare("UPDATE items SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
res.status(204).end();
});
// Restore soft-deleted item
router.post('/:id/restore', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ? AND deleted_at IS NOT NULL').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found or not deleted' });
return;
}
db.prepare('UPDATE items SET deleted_at = NULL WHERE id = ?').run(req.params.id);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item;
res.json(item);
});
// Permanently delete item and its files
router.delete('/:id/permanent', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found' });
return;
}
// Delete all files from disk
const files = db.prepare('SELECT * FROM item_files WHERE item_id = ?').all(req.params.id) as ItemFile[];
for (const file of files) {
try { unlinkSync(join(UPLOADS_DIR, file.stored_name)); } catch { /* ignore */ }
for (const variant of getVariantFilenames(file.stored_name)) {
try { unlinkSync(join(UPLOADS_DIR, variant)); } catch { /* ignore */ }
}
}
db.prepare('DELETE FROM items WHERE id = ?').run(req.params.id);
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,79 @@
import { Router } from 'express';
import db from '../db.js';
import type { StatsResponse } from '../types.js';
const router = Router();
router.get('/', (_req, res) => {
const totalActive = db.prepare(
"SELECT COUNT(*) as count FROM items WHERE status = 'active' AND deleted_at IS NULL"
).get() as { count: number };
const totalValue = db.prepare(
"SELECT COALESCE(SUM(purchase_price), 0) as total FROM items WHERE status = 'active' AND deleted_at IS NULL"
).get() as { total: number };
const statusCounts = db.prepare(
'SELECT status, COUNT(*) as count FROM items WHERE deleted_at IS NULL GROUP BY status'
).all() as { status: string; count: number }[];
const valueByCategory = db.prepare(
`SELECT category, COALESCE(SUM(purchase_price), 0) as value, COUNT(*) as count
FROM items WHERE status = 'active' AND deleted_at IS NULL AND category != ''
GROUP BY category ORDER BY value DESC`
).all() as { category: string; value: number; count: number }[];
const valueByLocation = db.prepare(
`SELECT location, COALESCE(SUM(purchase_price), 0) as value, COUNT(*) as count
FROM items WHERE status = 'active' AND deleted_at IS NULL AND location != ''
GROUP BY location ORDER BY value DESC`
).all() as { location: string; value: number; count: number }[];
const inflowOutflow = db.prepare(
`SELECT
strftime('%Y-%m', created_at) as month,
COUNT(*) as added,
0 as removed
FROM items WHERE deleted_at IS NULL
GROUP BY month
UNION ALL
SELECT
strftime('%Y-%m', updated_at) as month,
0 as added,
COUNT(*) as removed
FROM items
WHERE deleted_at IS NULL AND status IN ('sold', 'trashed', 'lost')
GROUP BY month
ORDER BY month`
).all() as { month: string; added: number; removed: number }[];
// Merge inflow/outflow by month
const monthMap = new Map<string, { month: string; added: number; removed: number }>();
for (const row of inflowOutflow) {
const existing = monthMap.get(row.month);
if (existing) {
existing.added += row.added;
existing.removed += row.removed;
} else {
monthMap.set(row.month, { ...row });
}
}
const countByStatus: Record<string, number> = {};
for (const row of statusCounts) {
countByStatus[row.status] = row.count;
}
const stats: StatsResponse = {
totalActiveItems: totalActive.count,
totalActiveValue: totalValue.total,
countByStatus,
valueByCategory,
valueByLocation,
inflowOutflow: Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)),
};
res.json(stats);
});
export default router;

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import db from '../db.js';
const router = Router();
router.get('/categories', (req, res) => {
const q = (req.query.q as string) || '';
let rows: { category: string }[];
if (q) {
rows = db.prepare(
"SELECT DISTINCT category FROM items WHERE deleted_at IS NULL AND category != '' AND category LIKE ? ORDER BY category"
).all(`${q}%`) as { category: string }[];
} else {
rows = db.prepare(
"SELECT DISTINCT category FROM items WHERE deleted_at IS NULL AND category != '' ORDER BY category"
).all() as { category: string }[];
}
res.json(rows.map(r => r.category));
});
router.get('/locations', (req, res) => {
const q = (req.query.q as string) || '';
let rows: { location: string }[];
if (q) {
rows = db.prepare(
"SELECT DISTINCT location FROM items WHERE deleted_at IS NULL AND location != '' AND location LIKE ? ORDER BY location"
).all(`${q}%`) as { location: string }[];
} else {
rows = db.prepare(
"SELECT DISTINCT location FROM items WHERE deleted_at IS NULL AND location != '' ORDER BY location"
).all() as { location: string }[];
}
res.json(rows.map(r => r.location));
});
export default router;

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import { v4 as uuid } from 'uuid';
import { unlinkSync } from 'fs';
import { join } from 'path';
import db from '../db.js';
import { upload, UPLOADS_DIR } from '../middleware/upload.js';
import { isResizable, generateVariants, getVariantFilenames } from '../resize.js';
import type { ItemFile } from '../types.js';
const router = Router();
// Serve uploaded files (originals + variants)
router.get('/:storedName', (req, res) => {
const filePath = join(UPLOADS_DIR, req.params.storedName);
res.sendFile(filePath, (err) => {
if (err) res.status(404).json({ error: 'File not found' });
});
});
export default router;
export function createFileUploadRouter() {
const fileRouter = Router({ mergeParams: true });
// Upload files for an item
fileRouter.post('/:itemId/files', upload.array('files', 10), async (req, res) => {
const itemId = req.params.itemId;
const item = db.prepare('SELECT id FROM items WHERE id = ?').get(itemId);
if (!item) {
res.status(404).json({ error: 'Item not found' });
return;
}
const fileType = (req.body.type as string) || 'photo';
const files = (req.files as Express.Multer.File[]) || [];
const results: ItemFile[] = [];
const stmt = db.prepare(`
INSERT INTO item_files (id, item_id, file_type, filename, stored_name, mime_type, size_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const file of files) {
const id = uuid();
stmt.run(id, itemId, fileType, file.originalname, file.filename, file.mimetype, file.size);
if (isResizable(file.mimetype)) {
await generateVariants(file.filename);
}
const record = db.prepare('SELECT * FROM item_files WHERE id = ?').get(id) as ItemFile;
results.push(record);
}
res.status(201).json(results);
});
// Set featured photo for an item
fileRouter.put('/:itemId/files/:fileId/featured', (req, res) => {
const { itemId, fileId } = req.params;
const file = db.prepare(
'SELECT * FROM item_files WHERE id = ? AND item_id = ?'
).get(fileId, itemId) as ItemFile | undefined;
if (!file) {
res.status(404).json({ error: 'File not found' });
return;
}
db.transaction(() => {
db.prepare(
"UPDATE item_files SET is_featured = 0 WHERE item_id = ? AND file_type = 'photo'"
).run(itemId);
db.prepare(
'UPDATE item_files SET is_featured = 1 WHERE id = ?'
).run(fileId);
})();
res.json({ success: true });
});
return fileRouter;
}
export function createFileDeleteRouter() {
const deleteRouter = Router();
deleteRouter.delete('/:fileId', (req, res) => {
const file = db.prepare('SELECT * FROM item_files WHERE id = ?').get(req.params.fileId) as ItemFile | undefined;
if (!file) {
res.status(404).json({ error: 'File not found' });
return;
}
// Delete original
try { unlinkSync(join(UPLOADS_DIR, file.stored_name)); } catch { /* ignore */ }
// Delete all variants
for (const variant of getVariantFilenames(file.stored_name)) {
try { unlinkSync(join(UPLOADS_DIR, variant)); } catch { /* ignore */ }
}
db.prepare('DELETE FROM item_files WHERE id = ?').run(req.params.fileId);
res.status(204).end();
});
return deleteRouter;
}

51
server/src/types.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface Item {
id: string;
label_id: number;
name: string;
category: string;
location: string;
purchase_date: string;
purchase_price: number | null;
status: ItemStatus;
notes: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
flagged: number;
}
export type ItemStatus = 'active' | 'stored' | 'lent_out' | 'sold' | 'lost' | 'trashed';
export interface ItemFile {
id: string;
item_id: string;
file_type: 'photo' | 'receipt';
filename: string;
stored_name: string;
mime_type: string;
size_bytes: number;
is_featured: number;
created_at: string;
}
export interface CreateItemBody {
name: string;
category?: string;
location?: string;
purchase_date?: string;
purchase_price?: number | null;
status?: ItemStatus;
notes?: string;
flagged?: number;
}
export interface UpdateItemBody extends Partial<CreateItemBody> {}
export interface StatsResponse {
totalActiveItems: number;
totalActiveValue: number;
countByStatus: Record<string, number>;
valueByCategory: { category: string; value: number; count: number }[];
valueByLocation: { location: string; value: number; count: number }[];
inflowOutflow: { month: string; added: number; removed: number }[];
}

10
server/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}