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:
parent
3a028e8f03
commit
7f437b52a9
6 changed files with 532 additions and 0 deletions
60
public/components/extensions.html
Normal file
60
public/components/extensions.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-phone-volume"></i> Pagers & 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>
|
||||
|
|
@ -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 & 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
295
public/js/extensions.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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
153
src/routes/extensions.js
Normal 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;
|
||||
Loading…
Reference in a new issue