// ============================================================ // PHONE EXTENSIONS / PAGERS — per-user personal directory // ============================================================ // Soft-delete via trashed_at: deleting moves to trash; user can restore // or purge. List supports async search (q) and trash toggle (trash=1). var express = require('express'); var multer = require('multer'); var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var transfer = require('../utils/extensionTransfer'); var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024 * 1024 } }); router.use(authMiddleware); function sanitizeType(t) { return transfer.sanitizeType(t); } function clip(s, n) { return transfer.clip(s, n); } function parseId(raw) { var n = parseInt(raw, 10); return (Number.isFinite(n) && n > 0) ? n : null; } // ── GET /api/extensions ───────────────────────────────────── // Query params: // trash=1 → return trashed items only (default: active only) // q=text → case-insensitive substring search on location/name/number/notes router.get('/extensions', async function (req, res) { try { var trashMode = req.query.trash === '1' || req.query.trash === 'true'; var q = (req.query.q || '').trim(); var sql = 'SELECT id, location, name, number, type, notes, trashed_at, created_at, updated_at ' + 'FROM user_phone_extensions WHERE user_id = $1 AND trashed_at IS ' + (trashMode ? 'NOT NULL' : 'NULL'); var params = [req.user.id]; if (q) { sql += ' AND (LOWER(location) LIKE $2 OR LOWER(name) LIKE $2 OR LOWER(number) LIKE $2 OR LOWER(notes) LIKE $2)'; params.push('%' + q.toLowerCase() + '%'); } sql += ' ORDER BY location ASC, type ASC, name ASC'; var rows = await db.all(sql, params); res.json({ success: true, items: rows }); } catch (e) { logger.error('GET /extensions', e.message); res.status(500).json({ error: 'Request failed' }); } }); // ── GET /api/extensions/export — ZIP export for sharing/import ───────── router.get('/extensions/export', async function (req, res) { try { var rows = await db.all( 'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1 AND trashed_at IS NULL ORDER BY location ASC, type ASC, name ASC', [req.user.id] ); var payload = transfer.exportPayload(rows); var zip = transfer.createJsonZip('pedscribe-extensions.json', payload); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', 'attachment; filename="pedscribe-extensions.zip"'); res.setHeader('X-Export-Count', String(payload.items.length)); res.send(zip); } catch (e) { logger.error('GET /extensions/export', e.message); res.status(500).json({ error: 'Export failed' }); } }); // ── POST /api/extensions/import/preview — preview another user's JSON export router.post('/extensions/import/preview', async function (req, res) { try { var items = transfer.importItemsFromBody(req.body); if (!items.length) return res.status(400).json({ error: 'No valid entries to import' }); var analysis = await analyzeImportForUser(req.user.id, items); res.json({ success: true, preview: analysis }); } catch (e) { logger.error('POST /extensions/import/preview', e.message); res.status(500).json({ error: 'Import preview failed' }); } }); // ── POST /api/extensions/import — import another user's JSON export ───── router.post('/extensions/import', async function (req, res) { try { var items = transfer.importItemsFromBody(req.body); var result = await importItemsForUser(req.user.id, items, parseImportOptions(req.body && req.body.options)); if (!result.success) return res.status(400).json(result); res.json(result); } catch (e) { logger.error('POST /extensions/import', e.message); res.status(500).json({ error: 'Import failed' }); } }); // ── POST /api/extensions/import-file/preview — preview JSON or ZIP file ─ router.post('/extensions/import-file/preview', upload.single('file'), async function (req, res) { try { if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' }); var parsed = transfer.parseImportBuffer(req.file.buffer); var items = transfer.importItemsFromBody(parsed); if (!items.length) return res.status(400).json({ error: 'No valid entries to import' }); var analysis = await analyzeImportForUser(req.user.id, items); res.json({ success: true, preview: analysis }); } catch (e) { logger.error('POST /extensions/import-file/preview', e.message); res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' }); } }); // ── POST /api/extensions/import-file — import JSON or ZIP file ───────── router.post('/extensions/import-file', upload.single('file'), async function (req, res) { try { if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' }); var parsed = transfer.parseImportBuffer(req.file.buffer); var items = transfer.importItemsFromBody(parsed); var result = await importItemsForUser(req.user.id, items, parseImportOptions(req.body)); if (!result.success) return res.status(400).json(result); res.json(result); } catch (e) { logger.error('POST /extensions/import-file', e.message); res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' }); } }); function parseImportOptions(raw) { raw = raw || {}; return { importPossibleDuplicates: raw.importPossibleDuplicates === true || raw.importPossibleDuplicates === 'true' || raw.importPossibleDuplicates === '1', restoreTrashed: raw.restoreTrashed === true || raw.restoreTrashed === 'true' || raw.restoreTrashed === '1' }; } async function getExistingRows(userId) { return db.all( 'SELECT id, location, name, number, type, notes, trashed_at FROM user_phone_extensions WHERE user_id = $1', [userId] ); } async function analyzeImportForUser(userId, items) { var existingRows = await getExistingRows(userId); return transfer.analyzeImport(items, existingRows); } async function importItemsForUser(userId, items, options) { if (!items.length) return { success: false, error: 'No valid entries to import' }; options = options || {}; var existingRows = await getExistingRows(userId); var analysis = transfer.analyzeImport(items, existingRows); var imported = 0; var skipped = 0; var restored = 0; var possibleSkipped = 0; for (var i = 0; i < analysis.entries.length; i++) { var entry = analysis.entries[i]; var item = entry.item; if (entry.status === 'exact_active') { skipped++; continue; } if (entry.status === 'exact_trashed') { if (options.restoreTrashed && entry.exactMatch && entry.exactMatch.id) { await db.run( 'UPDATE user_phone_extensions SET trashed_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 AND trashed_at IS NOT NULL', [entry.exactMatch.id, userId] ); restored++; } else { skipped++; } continue; } if (entry.status === 'possible_duplicate' && !options.importPossibleDuplicates) { skipped++; possibleSkipped++; continue; } await db.run( 'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)', [userId, item.location, item.name, item.number, item.type, item.notes] ); imported++; } return { success: true, imported: imported, restored: restored, skipped: skipped, possibleSkipped: possibleSkipped, total: items.length, preview: analysis.summary }; } // ── POST /api/extensions ──────────────────────────────────── router.post('/extensions', async function (req, res) { try { var location = clip(req.body.location, 120); var name = clip(req.body.name, 120); var number = clip(req.body.number, 40); var type = sanitizeType(req.body.type); var notes = clip(req.body.notes, 500); if (!location) return res.status(400).json({ error: 'Location is required' }); if (!name) return res.status(400).json({ error: 'Name / department is required' }); if (!number) return res.status(400).json({ error: 'Number is required' }); var result = await db.run( 'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)', [req.user.id, location, name, number, type, notes] ); res.json({ success: true, id: result.lastInsertRowid }); } catch (e) { logger.error('POST /extensions', e.message); res.status(500).json({ error: 'Request failed' }); } }); // ── PUT /api/extensions/:id ───────────────────────────────── router.put('/extensions/:id', async function (req, res) { try { var id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); var location = clip(req.body.location, 120); var name = clip(req.body.name, 120); var number = clip(req.body.number, 40); var type = sanitizeType(req.body.type); var notes = clip(req.body.notes, 500); if (!location || !name || !number) return res.status(400).json({ error: 'Location, name, and number are required' }); var result = await db.run( 'UPDATE user_phone_extensions SET location=$1, name=$2, number=$3, type=$4, notes=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7', [location, name, number, type, notes, id, req.user.id] ); if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); res.json({ success: true }); } catch (e) { logger.error('PUT /extensions/:id', e.message); res.status(500).json({ error: 'Request failed' }); } }); // ── DELETE /api/extensions/:id — soft-delete (move to trash) ── router.delete('/extensions/:id', async function (req, res) { try { var id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); var result = await db.run( 'UPDATE user_phone_extensions SET trashed_at = NOW(), updated_at = NOW() WHERE id=$1 AND user_id=$2 AND trashed_at IS NULL', [id, req.user.id] ); if (result.changes === 0) return res.status(404).json({ error: 'Not found or already trashed' }); res.json({ success: true }); } catch (e) { logger.error('DELETE /extensions/:id', e.message); res.status(500).json({ error: 'Request failed' }); } }); // ── POST /api/extensions/:id/restore — bring back from trash ── router.post('/extensions/:id/restore', async function (req, res) { try { var id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); var result = await db.run( 'UPDATE user_phone_extensions SET trashed_at = NULL, updated_at = NOW() WHERE id=$1 AND user_id=$2 AND trashed_at IS NOT NULL', [id, req.user.id] ); if (result.changes === 0) return res.status(404).json({ error: 'Not found in trash' }); res.json({ success: true }); } catch (e) { logger.error('POST /extensions/:id/restore', e.message); res.status(500).json({ error: 'Request failed' }); } }); // ── DELETE /api/extensions/:id/purge — hard delete (only from trash) ── router.delete('/extensions/:id/purge', async function (req, res) { try { var id = parseId(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid id' }); var result = await db.run( 'DELETE FROM user_phone_extensions WHERE id=$1 AND user_id=$2 AND trashed_at IS NOT NULL', [id, req.user.id] ); if (result.changes === 0) return res.status(404).json({ error: 'Must trash before purge' }); res.json({ success: true }); } catch (e) { logger.error('DELETE /extensions/:id/purge', e.message); res.status(500).json({ error: 'Request failed' }); } }); module.exports = router; module.exports._test = { importItemsForUser, analyzeImportForUser, parseImportOptions };