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:
27
server/package.json
Normal file
27
server/package.json
Normal 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
156
server/src/db.ts
Normal 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
41
server/src/index.ts
Normal 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}`);
|
||||
});
|
||||
37
server/src/middleware/upload.ts
Normal file
37
server/src/middleware/upload.ts
Normal 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
42
server/src/resize.ts
Normal 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
276
server/src/routes/items.ts
Normal 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;
|
||||
79
server/src/routes/stats.ts
Normal file
79
server/src/routes/stats.ts
Normal 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;
|
||||
36
server/src/routes/suggestions.ts
Normal file
36
server/src/routes/suggestions.ts
Normal 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;
|
||||
109
server/src/routes/uploads.ts
Normal file
109
server/src/routes/uploads.ts
Normal 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
51
server/src/types.ts
Normal 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
10
server/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user