diff --git a/public/components/extensions.html b/public/components/extensions.html new file mode 100644 index 0000000..6b7f8a6 --- /dev/null +++ b/public/components/extensions.html @@ -0,0 +1,60 @@ +
+

Pagers & Extensions

+

Your personal directory of hospital phone extensions and pagers — searchable, edit-friendly, soft-delete

+
+ +
+
+
+ + +
+ + +
+ + +
+ + + +
+

Loading...

+
diff --git a/public/index.html b/public/index.html index e612e11..fa46a96 100644 --- a/public/index.html +++ b/public/index.html @@ -236,6 +236,10 @@ Physical Exam Guide + ' + + ''; + } else { + actions = + '' + + ''; + } + + var html = ''; + html += '
'; + html += '
'; + html += '
'; + html += '
' + esc(x.number) + '
'; + html += ' ' + typeLabel + ''; + html += '
'; + html += '
' + esc(x.name) + '
'; + if (x.notes) html += '
' + esc(x.notes) + '
'; + html += '
'; + html += '
' + actions + '
'; + html += '
'; + return html; + } + + function onAddClick() { + _editId = null; + document.getElementById('ext-location').value = ''; + document.getElementById('ext-name').value = ''; + document.getElementById('ext-number').value = ''; + document.getElementById('ext-type').value = 'extension'; + document.getElementById('ext-notes').value = ''; + document.getElementById('ext-form-status').textContent = ''; + document.getElementById('ext-save-btn').innerHTML = ' Save'; + document.getElementById('ext-form-wrap').classList.remove('hidden'); + document.getElementById('ext-location').focus(); + } + + function hideForm() { + document.getElementById('ext-form-wrap').classList.add('hidden'); + _editId = null; + } + + function startEdit(id) { + var item = _items.filter(function (x) { return x.id === id; })[0]; + if (!item) return; + _editId = id; + document.getElementById('ext-location').value = item.location; + document.getElementById('ext-name').value = item.name; + document.getElementById('ext-number').value = item.number; + document.getElementById('ext-type').value = item.type; + document.getElementById('ext-notes').value = item.notes || ''; + document.getElementById('ext-form-status').textContent = ''; + document.getElementById('ext-save-btn').innerHTML = ' Update'; + document.getElementById('ext-form-wrap').classList.remove('hidden'); + document.getElementById('ext-location').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + function saveItem() { + var body = { + location: document.getElementById('ext-location').value.trim(), + name: document.getElementById('ext-name').value.trim(), + number: document.getElementById('ext-number').value.trim(), + type: document.getElementById('ext-type').value, + notes: document.getElementById('ext-notes').value.trim() + }; + if (!body.location || !body.name || !body.number) { + document.getElementById('ext-form-status').textContent = 'Location, name, and number are required.'; + document.getElementById('ext-form-status').style.color = 'var(--red)'; + return; + } + var url = _editId ? ('/api/extensions/' + _editId) : '/api/extensions'; + var method = _editId ? 'PUT' : 'POST'; + document.getElementById('ext-save-btn').disabled = true; + fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(body) }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) { showToast(d.error || 'Save failed', 'error'); return; } + hideForm(); + showToast(_editId ? 'Updated' : 'Added', 'success'); + load(); + }) + .catch(function () { showToast('Save failed', 'error'); }) + .finally(function () { document.getElementById('ext-save-btn').disabled = false; }); + } + + function confirmDelete(id) { + var item = _items.filter(function (x) { return x.id === id; })[0]; + if (!item) return; + if (!confirm('Move "' + item.name + ' (' + item.number + ')" to trash?\n\nYou can restore it later from the trash view.')) return; + fetch('/api/extensions/' + id, { method: 'DELETE', headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; } + showToast('Moved to trash', 'success'); + load(); + loadTrashCount(); + }) + .catch(function () { showToast('Delete failed', 'error'); }); + } + + function restoreItem(id) { + fetch('/api/extensions/' + id + '/restore', { method: 'POST', headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) { showToast(d.error || 'Restore failed', 'error'); return; } + showToast('Restored', 'success'); + load(); + loadTrashCount(); + }) + .catch(function () { showToast('Restore failed', 'error'); }); + } + + function confirmPurge(id) { + var item = _items.filter(function (x) { return x.id === id; })[0]; + if (!item) return; + if (!confirm('Permanently delete "' + item.name + ' (' + item.number + ')"?\n\nThis cannot be undone.')) return; + fetch('/api/extensions/' + id + '/purge', { method: 'DELETE', headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; } + showToast('Permanently deleted', 'success'); + load(); + loadTrashCount(); + }) + .catch(function () { showToast('Delete failed', 'error'); }); + } + + function toggleTrashMode() { + _mode = (_mode === 'trash') ? 'active' : 'trash'; + updateModeBanner(); + load(); + } + function updateModeBanner() { + var banner = document.getElementById('ext-mode-banner'); + var trashBtn = document.getElementById('ext-trash-btn'); + if (_mode === 'trash') { + banner.classList.remove('hidden'); + trashBtn.innerHTML = ' Active items'; + document.getElementById('ext-add-btn').style.display = 'none'; + } else { + banner.classList.add('hidden'); + trashBtn.innerHTML = ' Trash '; + document.getElementById('ext-add-btn').style.display = ''; + loadTrashCount(); + } + } + + function esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + +})(); diff --git a/server.js b/server.js index 6995d84..0e409ce 100644 --- a/server.js +++ b/server.js @@ -276,6 +276,7 @@ app.use('/api', require('./src/routes/hospitalCourse')); app.use('/api', require('./src/routes/chartReview')); app.use('/api', require('./src/routes/milestones')); app.use('/api', require('./src/routes/peGuide')); +app.use('/api', require('./src/routes/extensions')); app.use('/api', require('./src/routes/soap')); app.use('/api', require('./src/routes/tts')); app.use('/api', require('./src/routes/nextcloud')); diff --git a/src/db/database.js b/src/db/database.js index 9924131..b759365 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -190,6 +190,23 @@ async function initDatabase() { CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id); CREATE INDEX IF NOT EXISTS idx_memories_user_cat ON user_memories(user_id, category); + -- Phone extensions / pagers (per-user personal directory) + -- Soft-delete via trashed_at: null = active, timestamp = in trash. + CREATE TABLE IF NOT EXISTS user_phone_extensions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + location TEXT NOT NULL, + name TEXT NOT NULL, + number TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('extension', 'pager')), + notes TEXT DEFAULT '', + trashed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_ext_user_active ON user_phone_extensions(user_id) WHERE trashed_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_ext_user_trash ON user_phone_extensions(user_id) WHERE trashed_at IS NOT NULL; + -- Learning Hub tables CREATE TABLE IF NOT EXISTS learning_categories ( id SERIAL PRIMARY KEY, diff --git a/src/routes/extensions.js b/src/routes/extensions.js new file mode 100644 index 0000000..02e7616 --- /dev/null +++ b/src/routes/extensions.js @@ -0,0 +1,153 @@ +// ============================================================ +// 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;