feat(extensions): personal Pagers & Extensions directory with soft-delete

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
This commit is contained in:
Daniel 2026-04-22 18:54:26 +02:00
parent 3a028e8f03
commit 7f437b52a9
6 changed files with 532 additions and 0 deletions

View file

@ -0,0 +1,60 @@
<div class="module-header">
<h2><i class="fas fa-phone-volume"></i> Pagers &amp; Extensions</h2>
<p>Your personal directory of hospital phone extensions and pagers — searchable, edit-friendly, soft-delete</p>
</div>
<div class="card ext-toolbar">
<div style="display:flex;gap:10px;align-items:center;padding:12px 16px;flex-wrap:wrap;">
<div style="flex:1;min-width:220px;position:relative;">
<i class="fas fa-search" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--g400);font-size:13px;"></i>
<input type="search" id="ext-search" placeholder="Search by location, name, number..." autocomplete="off"
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
</div>
<button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button>
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
</div>
<div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
<div class="demo-field">
<label>Location</label>
<input type="text" id="ext-location" list="ext-location-list" placeholder="e.g., Main Hospital" maxlength="120">
<datalist id="ext-location-list"></datalist>
</div>
<div class="demo-field">
<label>Name / Department</label>
<input type="text" id="ext-name" placeholder="e.g., Nursery, 5B, On-call" maxlength="120">
</div>
<div class="demo-field">
<label>Number</label>
<input type="text" id="ext-number" placeholder="e.g., 5866" maxlength="40">
</div>
<div class="demo-field">
<label>Type</label>
<select id="ext-type">
<option value="extension">Extension</option>
<option value="pager">Pager</option>
</select>
</div>
</div>
<div style="margin-top:8px;">
<div class="demo-field">
<label>Notes (optional)</label>
<input type="text" id="ext-notes" placeholder="e.g., After hours: 5867" maxlength="500">
</div>
</div>
<div style="margin-top:10px;display:flex;gap:6px;">
<button id="ext-save-btn" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save</button>
<button id="ext-cancel-btn" class="btn-sm btn-ghost">Cancel</button>
<span id="ext-form-status" style="font-size:12px;margin-left:6px;color:var(--g500);align-self:center;"></span>
</div>
</div>
</div>
<div id="ext-mode-banner" class="hidden" style="margin:10px 0;padding:8px 12px;background:#fef3c7;border-left:4px solid #f59e0b;border-radius:6px;font-size:13px;color:#78350f;">
<i class="fas fa-trash-can"></i> Viewing trash. <button id="ext-back-active" class="btn-sm btn-ghost" style="margin-left:8px;">Back to active</button>
</div>
<div id="ext-list" style="margin-top:10px;">
<p style="text-align:center;color:#9ca3af;padding:40px;">Loading...</p>
</div>

View file

@ -236,6 +236,10 @@
<i class="fas fa-stethoscope"></i>
<span>Physical Exam Guide</span>
</button>
<button class="tab-btn" data-tab="extensions">
<i class="fas fa-phone-volume"></i>
<span>Pagers &amp; Extensions</span>
</button>
<button class="tab-btn" data-tab="calculators">
<i class="fas fa-calculator"></i>
<span>Calculators</span>
@ -290,6 +294,7 @@
<section id="vaxschedule-tab" class="tab-content" data-component="vaxschedule"></section>
<section id="catchup-tab" class="tab-content" data-component="catchup"></section>
<section id="peguide-tab" class="tab-content" data-component="pe-guide"></section>
<section id="extensions-tab" class="tab-content" data-component="extensions"></section>
<section id="calculators-tab" class="tab-content" data-component="calculators"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
@ -443,6 +448,7 @@
<script defer src="/js/soap.js"></script>
<script defer src="/js/milestones.js"></script>
<script defer src="/js/peGuide.js"></script>
<script defer src="/js/extensions.js"></script>
<script defer src="/js/nextcloud.js"></script>
<script defer src="/js/wellVisit.js"></script>
<script defer src="/js/shadess.js"></script>

295
public/js/extensions.js Normal file
View file

@ -0,0 +1,295 @@
// ============================================================
// PAGERS & EXTENSIONS — personal directory with async search + soft delete
// ============================================================
(function () {
var _inited = false;
var _items = [];
var _mode = 'active'; // 'active' | 'trash'
var _editId = null; // null = creating, non-null = editing
var _searchDebounce = null;
document.addEventListener('tabChanged', function (e) {
if (e.detail.tab !== 'extensions' || _inited) return;
_inited = true;
init();
});
function init() {
document.getElementById('ext-add-btn').addEventListener('click', onAddClick);
document.getElementById('ext-cancel-btn').addEventListener('click', hideForm);
document.getElementById('ext-save-btn').addEventListener('click', saveItem);
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
var searchInp = document.getElementById('ext-search');
searchInp.addEventListener('input', function () {
clearTimeout(_searchDebounce);
_searchDebounce = setTimeout(load, 200);
});
// Keyboard: Esc cancels form
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !document.getElementById('ext-form-wrap').classList.contains('hidden')) {
hideForm();
}
});
load();
loadTrashCount();
}
function loadTrashCount() {
fetch('/api/extensions?trash=1', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.success) {
var n = (d.items || []).length;
document.getElementById('ext-trash-count').textContent = n ? '(' + n + ')' : '';
}
})
.catch(function () {});
}
function load() {
var q = document.getElementById('ext-search').value.trim();
var url = '/api/extensions?' + (_mode === 'trash' ? 'trash=1&' : '') + (q ? 'q=' + encodeURIComponent(q) : '');
var listEl = document.getElementById('ext-list');
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:30px;">Loading...</p>';
fetch(url, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">' + esc(d.error || 'Load failed') + '</p>'; return; }
_items = d.items || [];
render();
refreshLocationList();
})
.catch(function (err) {
listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">Load failed: ' + esc(err.message) + '</p>';
});
}
function refreshLocationList() {
// Populate datalist with existing unique locations for autocomplete
var dl = document.getElementById('ext-location-list');
if (!dl) return;
var seen = {};
var opts = [];
_items.forEach(function (x) {
if (!seen[x.location]) { seen[x.location] = true; opts.push(x.location); }
});
dl.innerHTML = opts.map(function (l) { return '<option value="' + esc(l) + '">'; }).join('');
}
function render() {
var listEl = document.getElementById('ext-list');
if (_items.length === 0) {
var msg = _mode === 'trash' ? 'Trash is empty.' : 'No entries yet. Click Add to create your first one.';
var q = document.getElementById('ext-search').value.trim();
if (q) msg = 'No matches for "' + esc(q) + '".';
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">' + msg + '</p>';
return;
}
// Group by location → type
var byLoc = {};
_items.forEach(function (x) {
if (!byLoc[x.location]) byLoc[x.location] = { extension: [], pager: [] };
(byLoc[x.location][x.type] || byLoc[x.location].extension).push(x);
});
var html = '';
Object.keys(byLoc).sort().forEach(function (loc) {
html += '<div class="ext-loc-group" style="margin-bottom:18px;">';
html += '<h3 style="font-size:14px;color:var(--g700);margin:12px 0 8px;display:flex;align-items:center;gap:8px;"><i class="fas fa-map-marker-alt" style="color:var(--g400);"></i> ' + esc(loc) + '</h3>';
['extension', 'pager'].forEach(function (type) {
var arr = byLoc[loc][type];
if (!arr || arr.length === 0) return;
html += '<div style="margin-left:4px;">';
html += '<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--g500);font-weight:600;margin:6px 0 4px;">' + (type === 'pager' ? '📟 Pagers' : '☎️ Extensions') + '</div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;">';
arr.forEach(function (x) { html += renderCard(x); });
html += '</div></div>';
});
html += '</div>';
});
listEl.innerHTML = html;
// Wire per-card events
listEl.querySelectorAll('[data-ext-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.dataset.extAction;
var id = parseInt(btn.dataset.extId, 10);
if (!Number.isFinite(id) || id <= 0) return;
if (action === 'edit') return startEdit(id);
if (action === 'delete') return confirmDelete(id);
if (action === 'restore') return restoreItem(id);
if (action === 'purge') return confirmPurge(id);
});
});
}
function renderCard(x) {
var typeColor = x.type === 'pager' ? '#7c3aed' : '#2563eb';
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
var trashed = x.trashed_at != null;
var bg = trashed ? '#fafaf9' : 'var(--white, #fff)';
var border = trashed ? 'var(--g300)' : 'var(--g200)';
var actions;
if (trashed) {
actions =
'<button class="btn-sm btn-ghost" data-ext-action="restore" data-ext-id="' + x.id + '" title="Restore"><i class="fas fa-rotate-left"></i></button>' +
'<button class="btn-sm btn-ghost" data-ext-action="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
} else {
actions =
'<button class="btn-sm btn-ghost" data-ext-action="edit" data-ext-id="' + x.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
'<button class="btn-sm btn-ghost" data-ext-action="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
}
var html = '';
html += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px;display:flex;gap:12px;align-items:center;transition:border-color 0.15s,box-shadow 0.15s;">';
html += ' <div style="flex:1;min-width:0;">';
html += ' <div style="display:flex;align-items:baseline;gap:8px;">';
html += ' <div style="font-size:22px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;">' + esc(x.number) + '</div>';
html += ' <span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:6px;background:' + typeColor + '22;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.5px;">' + typeLabel + '</span>';
html += ' </div>';
html += ' <div style="font-size:13px;color:var(--g700);margin-top:2px;">' + esc(x.name) + '</div>';
if (x.notes) html += ' <div style="font-size:11px;color:var(--g500);margin-top:2px;">' + esc(x.notes) + '</div>';
html += ' </div>';
html += ' <div style="display:flex;gap:2px;flex-shrink:0;">' + actions + '</div>';
html += '</div>';
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 = '<i class="fas fa-floppy-disk"></i> 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 = '<i class="fas fa-check"></i> 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 = '<i class="fas fa-list"></i> Active items';
document.getElementById('ext-add-btn').style.display = 'none';
} else {
banner.classList.add('hidden');
trashBtn.innerHTML = '<i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span>';
document.getElementById('ext-add-btn').style.display = '';
loadTrashCount();
}
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();

View file

@ -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'));

View file

@ -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,

153
src/routes/extensions.js Normal file
View file

@ -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;