New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.
Data:
- New table user_phone_extensions (id, user_id, location, name, number,
type CHECK (extension|pager), notes, trashed_at, timestamps).
Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.
API (all user-scoped, all params validated):
- GET /api/extensions?trash=1&q=text — list active or trash, optional search
- POST /api/extensions — create
- PUT /api/extensions/:id — update (requires all three core fields)
- DELETE /api/extensions/:id — soft-delete (sets trashed_at)
- POST /api/extensions/:id/restore — un-trash
- DELETE /api/extensions/:id/purge — hard-delete (only if trashed)
All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.
UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
153 lines
6.3 KiB
JavaScript
153 lines
6.3 KiB
JavaScript
// ============================================================
|
|
// 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 router = express.Router();
|
|
var db = require('../db/database');
|
|
var { authMiddleware } = require('../middleware/auth');
|
|
var logger = require('../utils/logger');
|
|
|
|
router.use(authMiddleware);
|
|
|
|
function sanitizeType(t) {
|
|
return (t === 'pager') ? 'pager' : 'extension';
|
|
}
|
|
function clip(s, n) {
|
|
return String(s == null ? '' : s).trim().substring(0, n || 200);
|
|
}
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// ── 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;
|