v3.0.0: Auth, admin panel, security fixes, per-tab model selector

- Add authMiddleware to all AI/transcribe routes (were unauthenticated)
- Add full admin panel: user management, registration toggle, stats
- Fix XSS in email verification (escape user.name in HTML)
- Fix missing APP_URL fallback in password reset email
- Add per-tab model selector (respects OpenRouter/Bedrock/Azure lists)
- Fix transcribeAudio to send Authorization header
- Fix labs input: textarea instead of single-line input
- Add structured logging: audit_log, api_log, access_log tables
- Add admin CLI (admin-cli.js) for Docker exec management
- Fix duplicate var duration declaration in ai.js catch block
- Fix RETURNING check case-sensitivity in database.js
This commit is contained in:
ifedan-ed 2026-03-21 19:25:51 -04:00
parent 565bec9ca8
commit ac8e7bb890
32 changed files with 2249 additions and 814 deletions

View file

@ -1,5 +1,28 @@
# AI APIs (required) # ============================================================
# AI PROVIDER (choose one)
# ============================================================
# Option 1: OpenRouter (default, cheapest, NOT HIPAA)
AI_PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-v1-your-key OPENROUTER_API_KEY=sk-or-v1-your-key
# Option 2: AWS Bedrock (HIPAA compliant with BAA)
# AI_PROVIDER=bedrock
# AWS_BEDROCK_REGION=us-east-1
# AWS_ACCESS_KEY_ID=AKIA...
# AWS_SECRET_ACCESS_KEY=...
# (Or use IAM role — no keys needed if running on EC2/ECS)
# Option 3: Azure OpenAI (HIPAA compliant with BAA)
# AI_PROVIDER=azure
# AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
# AZURE_OPENAI_API_KEY=your-key
# AZURE_DEPLOYMENT_NAME=gpt-4o-mini
# AZURE_OPENAI_API_VERSION=2024-02-01
# ============================================================
# Whisper (always OpenAI for now)
# ============================================================
OPENAI_API_KEY=sk-your-openai-key OPENAI_API_KEY=sk-your-openai-key
# Optional # Optional
@ -20,3 +43,9 @@ SMTP_FROM=noreply@yourdomain.com
# Nextcloud (optional) # Nextcloud (optional)
NEXTCLOUD_URL=https://cloud.yourdomain.com NEXTCLOUD_URL=https://cloud.yourdomain.com
# ============================================================
# DATABASE
# ============================================================
DATABASE_URL=postgresql://pedscribe:<password>@postgres:5432/pedscribe
DB_PASSWORD=pedscribe_secret_change_me

View file

@ -1,19 +1,18 @@
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
RUN npm install --production RUN npm install --omit=dev
COPY . . COPY . .
RUN mkdir -p /app/data RUN mkdir -p /app/data/logs
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"] CMD ["node", "server.js"]

187
admin-cli.js Normal file
View file

@ -0,0 +1,187 @@
#!/usr/bin/env node
// ============================================================
// ADMIN CLI — Command line admin tool
// Run inside Docker: docker exec pediatric-ai-scribe node admin-cli.js <command>
// ============================================================
require('dotenv').config();
var db = require('./src/db/database');
var bcrypt = require('bcryptjs');
var args = process.argv.slice(2);
var command = args[0];
async function main() {
// Verify DB connection before proceeding
await db.query('SELECT 1', []);
switch (command) {
case 'make-admin': {
var email = args[1];
if (!email) { console.log('Usage: node admin-cli.js make-admin <email>'); process.exit(1); }
var user = await db.get('SELECT id, name, role FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
await db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', user.id]);
console.log('✅ ' + user.name + ' (' + email + ') is now ADMIN');
break;
}
case 'remove-admin': {
var email = args[1];
if (!email) { console.log('Usage: node admin-cli.js remove-admin <email>'); process.exit(1); }
var user = await db.get('SELECT id, name, role FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
await db.run('UPDATE users SET role = ? WHERE id = ?', ['user', user.id]);
console.log('✅ ' + user.name + ' (' + email + ') is now regular user');
break;
}
case 'verify-user': {
var email = args[1];
if (!email) { console.log('Usage: node admin-cli.js verify-user <email>'); process.exit(1); }
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [user.id]);
console.log('✅ ' + user.name + ' (' + email + ') email verified');
break;
}
case 'disable-user': {
var email = args[1];
if (!email) { console.log('Usage: node admin-cli.js disable-user <email>'); process.exit(1); }
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
await db.run('UPDATE users SET disabled = true WHERE id = ?', [user.id]);
console.log('✅ ' + user.name + ' (' + email + ') DISABLED');
break;
}
case 'enable-user': {
var email = args[1];
if (!email) { console.log('Usage: node admin-cli.js enable-user <email>'); process.exit(1); }
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
await db.run('UPDATE users SET disabled = false WHERE id = ?', [user.id]);
console.log('✅ ' + user.name + ' (' + email + ') ENABLED');
break;
}
case 'delete-user': {
var email = args[1];
if (!email) { console.log('Usage: node admin-cli.js delete-user <email>'); process.exit(1); }
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
await db.run('DELETE FROM users WHERE id = ?', [user.id]);
console.log('✅ ' + user.name + ' (' + email + ') DELETED');
break;
}
case 'reset-password': {
var email = args[1];
var newPass = args[2];
if (!email || !newPass) { console.log('Usage: node admin-cli.js reset-password <email> <new-password>'); process.exit(1); }
if (newPass.length < 8) { console.log('❌ Password must be 8+ characters'); process.exit(1); }
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
var hash = await bcrypt.hash(newPass, 12);
await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, user.id]);
console.log('✅ Password reset for ' + user.name + ' (' + email + ')');
break;
}
case 'create-admin': {
var email = args[1];
var password = args[2];
var name = args[3];
if (!email || !password || !name) {
console.log('Usage: node admin-cli.js create-admin <email> <password> <name>');
process.exit(1);
}
if (password.length < 8) { console.log('❌ Password must be 8+ characters'); process.exit(1); }
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) { console.log('❌ Email already exists: ' + email); process.exit(1); }
var hash = await bcrypt.hash(password, 12);
await db.run(
'INSERT INTO users (email, password, name, role, email_verified) VALUES (?, ?, ?, ?, true)',
[email.toLowerCase(), hash, name, 'admin']
);
console.log('✅ Admin user created: ' + name + ' (' + email + ')');
break;
}
case 'list-users': {
var users = await db.all('SELECT id, email, name, role, email_verified, disabled, created_at FROM users ORDER BY id', []);
console.log('');
console.log('ID | Email | Name | Role | Verified | Disabled');
console.log('---+---------------------------+-------------------+-------+----------+---------');
users.forEach(function(u) {
console.log(
String(u.id).padEnd(3) + '| ' +
u.email.padEnd(26) + '| ' +
(u.name || '').padEnd(18) + '| ' +
(u.role || 'user').padEnd(6) + '| ' +
(u.email_verified ? '✅' : '❌').padEnd(9) + '| ' +
(u.disabled ? '🚫 Yes' : '✅ No')
);
});
console.log('');
console.log('Total: ' + users.length + ' users');
break;
}
case 'toggle-registration': {
var current = await db.getSetting('registration_enabled');
var newValue = current === 'false' ? 'true' : 'false';
await db.setSetting('registration_enabled', newValue);
console.log('✅ Registration is now: ' + (newValue === 'true' ? 'ENABLED' : 'DISABLED'));
break;
}
case 'stats': {
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []);
var todayApi = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []);
var regEnabled = await db.getSetting('registration_enabled');
console.log('');
console.log('📊 App Statistics');
console.log('─────────────────');
console.log('Users: ' + (userCount ? userCount.count : 0));
console.log('Total API calls: ' + (apiCount ? apiCount.count : 0));
console.log('Today API calls: ' + (todayApi ? todayApi.count : 0));
console.log('Registration: ' + (regEnabled !== 'false' ? '✅ Enabled' : '❌ Disabled'));
console.log('');
break;
}
default:
console.log('');
console.log('🏥 Pediatric AI Scribe — Admin CLI');
console.log('');
console.log('Usage: docker exec pediatric-ai-scribe node admin-cli.js <command>');
console.log('');
console.log('User Management:');
console.log(' list-users List all users');
console.log(' create-admin <email> <password> <name> Create admin user');
console.log(' make-admin <email> Promote user to admin');
console.log(' remove-admin <email> Demote admin to user');
console.log(' verify-user <email> Verify user email');
console.log(' disable-user <email> Disable user account');
console.log(' enable-user <email> Enable user account');
console.log(' delete-user <email> Delete user permanently');
console.log(' reset-password <email> <new-password> Reset user password');
console.log('');
console.log('App Settings:');
console.log(' toggle-registration Toggle registration on/off');
console.log(' stats Show app statistics');
console.log('');
}
process.exit(0);
}
main().catch(function(err) {
console.error('❌ Error:', err.message);
process.exit(1);
});

View file

@ -1,13 +1,16 @@
services: services:
pediatric-scribe: pediatric-scribe:
image: danielonyejesi/pediatric-ai-scribe:latest
build: . build: .
image: danielonyejesi/pediatric-ai-scribe-v3:latest
ports: ports:
- "3552:3000" - "3552:3000"
env_file: env_file:
- .env - .env
volumes: volumes:
- scribe-data:/app/data - scribe-logs:/app/data/logs
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
container_name: pediatric-ai-scribe container_name: pediatric-ai-scribe
healthcheck: healthcheck:
@ -15,7 +18,25 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
start_period: 15s start_period: 20s
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
container_name: pedscribe-db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedscribe"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes: volumes:
scribe-data: pgdata:
scribe-logs:

View file

@ -1,6 +1,6 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "2.0.0", "version": "2.1.0",
"description": "AI-powered pediatric clinical documentation platform", "description": "AI-powered pediatric clinical documentation platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -13,16 +13,17 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"better-sqlite3": "^11.6.0", "pg": "^8.13.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"crypto": "^1.0.1",
"@simplewebauthn/server": "^10.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"express-rate-limit": "^7.4.0" "express-rate-limit": "^7.4.0"
},
"optionalDependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.700.0"
} }
} }

View file

@ -219,7 +219,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.toast-success{background:var(--green);}.toast-error{background:var(--red);}.toast-info{background:var(--blue);} .toast-success{background:var(--green);}.toast-error{background:var(--red);}.toast-info{background:var(--blue);}
@keyframes slideIn{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}} @keyframes slideIn{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}}
.hidden{display:none !important;} .hidden{display:none;}
/* RESPONSIVE */ /* RESPONSIVE */
@media(max-width:768px){ @media(max-width:768px){
@ -242,3 +242,28 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;} .editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;}
.editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;} .editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;}
/* Per-tab model selector */
.demo-field-model { flex: 1; min-width: 180px; }
.demo-field-model select { min-width: 180px; max-width: 260px; }
/* Labs textarea */
.visit-labs, .lab-values {
width: 100%; padding: 8px 12px; border: 1.5px solid var(--g300);
border-radius: 8px; font-size: 13px; font-family: inherit;
resize: vertical; margin-top: 8px; line-height: 1.5;
}
.visit-labs:focus, .lab-values:focus {
outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-light);
}
/* Admin Panel */
.admin-stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;}
.stat-card{flex:1;min-width:120px;background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:16px;text-align:center;}
.stat-value{font-size:28px;font-weight:700;color:var(--blue);}
.stat-label{font-size:12px;color:var(--g500);margin-top:4px;}
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
.admin-table th{text-align:left;padding:10px 16px;background:var(--g50);border-bottom:2px solid var(--g200);font-weight:600;color:var(--g600);font-size:12px;text-transform:uppercase;letter-spacing:0.4px;}
.admin-table td{padding:10px 16px;border-bottom:1px solid var(--g100);vertical-align:middle;}
.admin-table tr:last-child td{border-bottom:none;}
.admin-table tr:hover td{background:var(--g50);}

View file

@ -147,6 +147,10 @@
<i class="fas fa-baby"></i> <i class="fas fa-baby"></i>
<span>Milestones</span> <span>Milestones</span>
</button> </button>
<button class="tab-btn hidden" data-tab="admin" id="admin-tab-btn">
<i class="fas fa-user-shield"></i>
<span>Admin</span>
</button>
</nav> </nav>
<main class="main-content"> <main class="main-content">
@ -175,6 +179,10 @@
<option value="ed">Emergency Dept</option> <option value="ed">Emergency Dept</option>
</select> </select>
</div> </div>
<div class="demo-field demo-field-model">
<label><i class="fas fa-robot"></i> Model</label>
<select class="tab-model-select"></select>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -228,6 +236,10 @@
<option value="ed">Emergency Dept</option> <option value="ed">Emergency Dept</option>
</select> </select>
</div> </div>
<div class="demo-field demo-field-model">
<label><i class="fas fa-robot"></i> Model</label>
<select class="tab-model-select"></select>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -302,6 +314,10 @@
<option value="organSystem">Organ System (ICU)</option> <option value="organSystem">Organ System (ICU)</option>
</select> </select>
</div> </div>
<div class="demo-field demo-field-model">
<label><i class="fas fa-robot"></i> Model</label>
<select class="tab-model-select"></select>
</div>
</div> </div>
<!-- ED Note --> <!-- ED Note -->
@ -425,6 +441,10 @@
<option value="ed">ED Visit</option> <option value="ed">ED Visit</option>
</select> </select>
</div> </div>
<div class="demo-field demo-field-model">
<label><i class="fas fa-robot"></i> Model</label>
<select class="tab-model-select"></select>
</div>
</div> </div>
<!-- Visits --> <!-- Visits -->
@ -446,7 +466,7 @@
<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"> <input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)">
</div> </div>
<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div> <div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>
<input type="text" class="visit-labs" placeholder="Labs from this visit (optional)"> <textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea>
</div> </div>
</div> </div>
</div> </div>
@ -459,7 +479,7 @@
<div id="cr-labs-container"> <div id="cr-labs-container">
<div class="lab-entry"> <div class="lab-entry">
<input type="date" class="lab-date"> <input type="date" class="lab-date">
<input type="text" class="lab-values" placeholder="Lab results..."> <textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>
</div> </div>
</div> </div>
</div> </div>
@ -507,6 +527,10 @@
<option value="subjective">Subjective Only</option> <option value="subjective">Subjective Only</option>
</select> </select>
</div> </div>
<div class="demo-field demo-field-model">
<label><i class="fas fa-robot"></i> Model</label>
<select class="tab-model-select"></select>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -582,6 +606,10 @@
<option value="list">Structured List</option> <option value="list">Structured List</option>
</select> </select>
</div> </div>
<div class="demo-field demo-field-model">
<label><i class="fas fa-robot"></i> Model</label>
<select class="tab-model-select"></select>
</div>
</div> </div>
<div class="legend"> <div class="legend">
@ -627,6 +655,54 @@
</div> </div>
</section> </section>
<!-- ===== TAB: ADMIN ===== -->
<section id="admin-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-user-shield"></i> Admin Panel</h2>
<p>Manage users, app settings, and view usage statistics</p>
</div>
<!-- Stats -->
<div class="admin-stats" id="admin-stats">
<div class="stat-card"><div class="stat-value" id="stat-users"></div><div class="stat-label">Total Users</div></div>
<div class="stat-card"><div class="stat-value" id="stat-api-total"></div><div class="stat-label">Total API Calls</div></div>
<div class="stat-card"><div class="stat-value" id="stat-api-today"></div><div class="stat-label">API Calls Today</div></div>
</div>
<!-- Registration -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-door-open"></i> Registration</h3></div>
<div style="padding:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
<button id="btn-toggle-reg" class="btn-sm btn-primary">Toggle</button>
</div>
</div>
<!-- Users -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users"></i> Users</h3>
<button id="btn-refresh-users" class="btn-sm btn-ghost"><i class="fas fa-sync-alt"></i> Refresh</button>
</div>
<div style="overflow-x:auto;">
<table class="admin-table" id="admin-users-table">
<thead>
<tr>
<th>Name / Email</th>
<th>Role</th>
<th>Status</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="admin-users-body">
<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main> </main>
<!-- SETTINGS MODAL --> <!-- SETTINGS MODAL -->
@ -718,5 +794,6 @@
<script src="/js/soap.js"></script> <script src="/js/soap.js"></script>
<script src="/js/milestones.js"></script> <script src="/js/milestones.js"></script>
<script src="/js/nextcloud.js"></script> <script src="/js/nextcloud.js"></script>
<script src="/js/admin.js"></script>
</body> </body>
</html> </html>

231
public/js/admin.js Normal file
View file

@ -0,0 +1,231 @@
// ============================================================
// ADMIN.JS — Admin panel: users, settings, stats
// ============================================================
(function() {
var loaded = false;
// Load admin panel when tab is clicked
document.addEventListener('click', function(e) {
var btn = e.target.closest('.tab-btn');
if (btn && btn.getAttribute('data-tab') === 'admin') {
if (!loaded) { loadAdmin(); loaded = true; }
}
// Refresh button
if (e.target.closest('#btn-refresh-users')) {
loadUsers();
}
// Toggle registration
if (e.target.closest('#btn-toggle-reg')) {
toggleRegistration();
}
});
function loadAdmin() {
loadSettings();
loadUsers();
}
// ---- SETTINGS + STATS ----
function loadSettings() {
fetch('/api/admin/settings', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var stats = data.stats || {};
var settings = data.settings || {};
var el = document.getElementById('stat-users');
if (el) el.textContent = stats.totalUsers !== undefined ? stats.totalUsers : '—';
el = document.getElementById('stat-api-total');
if (el) el.textContent = stats.totalApiCalls !== undefined ? stats.totalApiCalls : '—';
el = document.getElementById('stat-api-today');
if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—';
updateRegStatus(settings.registrationEnabled !== false);
})
.catch(function(err) { console.error('[Admin] Settings load failed:', err); });
}
function updateRegStatus(enabled) {
var text = document.getElementById('reg-status-text');
var btn = document.getElementById('btn-toggle-reg');
if (text) {
text.innerHTML = 'Registration is currently <strong style="color:' + (enabled ? 'var(--green)' : 'var(--red)') + '">' + (enabled ? '✅ Enabled' : '❌ Disabled') + '</strong>';
}
if (btn) btn.textContent = enabled ? 'Disable Registration' : 'Enable Registration';
window._adminRegEnabled = enabled;
}
function toggleRegistration() {
var newVal = !window._adminRegEnabled;
fetch('/api/admin/settings/registration', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enabled: newVal })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
updateRegStatus(newVal);
showToast('Registration ' + (newVal ? 'enabled' : 'disabled'), 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
// ---- USERS ----
function loadUsers() {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>';
fetch('/api/admin/users', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Failed to load users</td></tr>'; return; }
renderUsers(data.users || []);
})
.catch(function() { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Request failed</td></tr>'; });
}
function renderUsers(users) {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">No users found</td></tr>';
return;
}
tbody.innerHTML = users.map(function(u) {
var isSelf = currentUser.id && u.id === currentUser.id;
var roleColor = u.role === 'admin' ? 'var(--blue)' : 'var(--g500)';
var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
var actions = [];
if (!isSelf) {
if (!u.email_verified) {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="verify" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Verify</button>');
}
if (u.disabled) {
actions.push('<button class="btn-sm btn-success admin-action" data-action="enable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Enable</button>');
} else {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="disable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Disable</button>');
}
if (u.role === 'admin') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="demote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Demote</button>');
} else {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
}
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
} else {
actions.push('<span style="font-size:12px;color:var(--g400);">(you)</span>');
}
return '<tr>' +
'<td><div style="font-weight:600;font-size:13px;">' + esc(u.name) + '</div><div style="font-size:11px;color:var(--g500);">' + esc(u.email) + '</div></td>' +
'<td><span style="font-size:12px;font-weight:600;color:' + roleColor + ';">' + (u.role || 'user') + '</span></td>' +
'<td><span style="font-size:12px;color:' + statusColor + ';">' + statusText + '</span></td>' +
'<td style="font-size:12px;color:var(--g500);">' + joined + '</td>' +
'<td style="white-space:nowrap;">' + actions.join(' ') + '</td>' +
'</tr>';
}).join('');
// Bind action buttons
tbody.querySelectorAll('.admin-action').forEach(function(btn) {
btn.addEventListener('click', function() {
handleUserAction(btn.dataset.action, btn.dataset.id, btn.dataset.email, btn.dataset.name);
});
});
}
function handleUserAction(action, userId, email, name) {
switch (action) {
case 'verify':
if (!confirm('Verify email for ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/verify', {}, function() {
showToast(email + ' verified', 'success'); loadUsers();
});
break;
case 'disable':
if (!confirm('Disable account for ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/disable', {}, function() {
showToast(email + ' disabled', 'info'); loadUsers();
});
break;
case 'enable':
adminPost('/api/admin/users/' + userId + '/enable', {}, function() {
showToast(email + ' enabled', 'success'); loadUsers();
});
break;
case 'promote':
if (!confirm('Grant admin role to ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() {
showToast(email + ' is now admin', 'success'); loadUsers();
});
break;
case 'demote':
if (!confirm('Remove admin role from ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' demoted to user', 'info'); loadUsers();
});
break;
case 'reset-pw':
var newPw = prompt('New password for ' + email + ' (8+ characters):');
if (!newPw) return;
if (newPw.length < 8) { showToast('Password must be 8+ characters', 'error'); return; }
adminPost('/api/admin/users/' + userId + '/reset-password', { newPassword: newPw }, function() {
showToast('Password reset for ' + email, 'success');
});
break;
case 'delete':
if (!confirm('Permanently delete user ' + (name || email) + '?\n\nThis cannot be undone.')) return;
fetch('/api/admin/users/' + userId, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast(email + ' deleted', 'info'); loadUsers(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
break;
}
}
function adminPost(url, body, onSuccess) {
fetch(url, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { onSuccess(data); }
else showToast(data.error || 'Request failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();

View file

@ -1,61 +1,111 @@
// ============================================================ // ============================================================
// APP.JS — Core utilities, tabs, model selector, refine, speak // APP.JS — Core utilities, tabs, model selector, helpers
// ============================================================ // ============================================================
// --- TAB NAVIGATION --- document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-btn').forEach(function(btn) {
// --- TAB NAVIGATION ---
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
btn.classList.add('active'); btn.classList.add('active');
document.getElementById(btn.getAttribute('data-tab') + '-tab').classList.add('active'); var tabId = btn.getAttribute('data-tab') + '-tab';
var tabEl = document.getElementById(tabId);
if (tabEl) tabEl.classList.add('active');
});
}); });
});
// --- MODEL SELECTOR --- // --- MODEL SELECTOR ---
var modelSelect = document.getElementById('global-model-select'); var modelSelect = document.getElementById('global-model-select');
var costBadge = document.getElementById('model-cost-badge'); var costBadge = document.getElementById('model-cost-badge');
window._currentModels = [];
window._currentProvider = 'openrouter';
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) { fetch('/api/models')
if (data.models && modelSelect) { .then(function(r) { return r.json(); })
modelSelect.innerHTML = ''; .then(function(data) {
data.models.forEach(function(m) { window._currentModels = data.models || [];
window._currentProvider = data.provider || 'openrouter';
var categories = { free: '🆓 Free', fast: '⚡ Fast & Cheap', smart: '🧠 Smart', premium: '👑 Premium' };
function buildModelOptions(selectEl) {
selectEl.innerHTML = '';
var grouped = {};
window._currentModels.forEach(function(m) {
var cat = m.category || 'smart';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(m);
});
Object.keys(categories).forEach(function(cat) {
if (!grouped[cat] || grouped[cat].length === 0) return;
var optgroup = document.createElement('optgroup');
optgroup.label = categories[cat];
grouped[cat].forEach(function(m) {
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = m.id; opt.value = m.id;
var prefix = m.tag === 'FREE' ? '🆓' : m.tag === 'PREMIUM' ? '👑' : m.tag === 'REASONING' ? '🧠' : '⚡'; opt.textContent = m.name + ' (' + m.cost + ')';
opt.textContent = prefix + ' ' + m.name + ' (' + m.cost + ')'; optgroup.appendChild(opt);
modelSelect.appendChild(opt); });
selectEl.appendChild(optgroup);
}); });
if (costBadge) costBadge.textContent = data.models[0].cost;
} }
}).catch(function() {});
function getSelectedModel() { if (modelSelect && window._currentModels.length > 0) {
return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash'; buildModelOptions(modelSelect);
} if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + window._currentModels[0].cost;
}
if (modelSelect) { // Populate all per-tab model selectors
modelSelect.addEventListener('change', function() { document.querySelectorAll('.tab-model-select').forEach(function(sel) {
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) { buildModelOptions(sel);
var m = data.models.find(function(x) { return x.id === modelSelect.value; });
if (m && costBadge) costBadge.textContent = m.cost;
}); });
})
.catch(function(err) { console.warn('Models load failed:', err); });
if (modelSelect) {
modelSelect.addEventListener('change', function() {
var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
if (m && costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + m.cost;
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info'); showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
}); });
}
console.log('✅ App.js DOM ready');
}); // end DOMContentLoaded
// ============================================================
// GLOBAL FUNCTIONS (must be outside DOMContentLoaded)
// ============================================================
function getSelectedModel() {
// Prefer the active tab's own model selector if present
var activeTab = document.querySelector('.tab-content.active');
if (activeTab) {
var tabSel = activeTab.querySelector('.tab-model-select');
if (tabSel && tabSel.value) return tabSel.value;
}
var sel = document.getElementById('global-model-select');
return sel ? sel.value : 'google/gemini-2.5-flash';
} }
// --- LOADING ---
function showLoading(text) { function showLoading(text) {
document.getElementById('loading-text').textContent = text || 'Processing...'; var overlay = document.getElementById('loading-overlay');
document.getElementById('loading-overlay').classList.remove('hidden'); var textEl = document.getElementById('loading-text');
} if (textEl) textEl.textContent = text || 'Processing...';
function hideLoading() { if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); }
document.getElementById('loading-overlay').classList.add('hidden'); }
function hideLoading() {
var overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
} }
// --- TOAST ---
function showToast(message, type) { function showToast(message, type) {
var container = document.getElementById('toast-container'); var container = document.getElementById('toast-container');
if (!container) { console.log('Toast:', type, message); return; }
var toast = document.createElement('div'); var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success'); toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle'; var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
@ -64,9 +114,9 @@ function showToast(message, type) {
setTimeout(function() { toast.remove(); }, 3500); setTimeout(function() { toast.remove(); }, 3500);
} }
// --- COPY ---
function copyText(elementId) { function copyText(elementId) {
var el = document.getElementById(elementId); var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText || el.textContent; var text = el.innerText || el.textContent;
navigator.clipboard.writeText(text).then(function() { navigator.clipboard.writeText(text).then(function() {
showToast('Copied!', 'success'); showToast('Copied!', 'success');
@ -81,7 +131,7 @@ function copyText(elementId) {
}); });
} }
// --- SPEAK / STOP --- // Speak / Stop
var currentlyReadingId = null; var currentlyReadingId = null;
var currentAudio = null; var currentAudio = null;
@ -89,14 +139,9 @@ function speakText(elementId) {
if (currentlyReadingId === elementId) { stopReading(); return; } if (currentlyReadingId === elementId) { stopReading(); return; }
stopReading(); stopReading();
var el = document.getElementById(elementId); var el = document.getElementById(elementId);
if (!el) return;
var text = (el.innerText || el.textContent).trim(); var text = (el.innerText || el.textContent).trim();
if (!text) { showToast('Nothing to read', 'error'); return; } if (!text) { showToast('Nothing to read', 'error'); return; }
var readBtn = findReadButton(elementId);
useBrowserTTS(text, elementId, readBtn);
}
function useBrowserTTS(text, elementId, readBtn) {
if (!('speechSynthesis' in window)) { showToast('TTS not supported', 'error'); return; } if (!('speechSynthesis' in window)) { showToast('TTS not supported', 'error'); return; }
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
var utter = new SpeechSynthesisUtterance(text); var utter = new SpeechSynthesisUtterance(text);
@ -104,7 +149,8 @@ function useBrowserTTS(text, elementId, readBtn) {
utter.onend = function() { stopReading(); }; utter.onend = function() { stopReading(); };
utter.onerror = function() { stopReading(); }; utter.onerror = function() { stopReading(); };
currentlyReadingId = elementId; currentlyReadingId = elementId;
setReadButtonActive(readBtn, true); var btn = findReadButton(elementId);
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
window.speechSynthesis.speak(utter); window.speechSynthesis.speak(utter);
showToast('Reading — click Stop to end', 'info'); showToast('Reading — click Stop to end', 'info');
} }
@ -113,7 +159,8 @@ function stopReading() {
if ('speechSynthesis' in window) window.speechSynthesis.cancel(); if ('speechSynthesis' in window) window.speechSynthesis.cancel();
if (currentAudio) { currentAudio.pause(); currentAudio = null; } if (currentAudio) { currentAudio.pause(); currentAudio = null; }
if (currentlyReadingId) { if (currentlyReadingId) {
setReadButtonActive(findReadButton(currentlyReadingId), false); var btn = findReadButton(currentlyReadingId);
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-up"></i> Read'; }
currentlyReadingId = null; currentlyReadingId = null;
} }
} }
@ -131,23 +178,17 @@ function findReadButton(elementId) {
return null; return null;
} }
function setReadButtonActive(btn, active) { // Timer
if (!btn) return;
if (active) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
else { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-up"></i> Read'; }
}
// --- TIMER ---
function createTimer(el) { function createTimer(el) {
var s = 0, iv = null; var s = 0, iv = null;
return { return {
start: function() { s = 0; this.update(); var self = this; iv = setInterval(function() { s++; self.update(); }, 1000); }, start: function() { s = 0; this.update(); var self = this; iv = setInterval(function() { s++; self.update(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; }, stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
update: function() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); } update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }
}; };
} }
// --- AUDIO RECORDER --- // Audio Recorder
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; } function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
AudioRecorder.prototype.start = function() { AudioRecorder.prototype.start = function() {
var self = this; self.chunks = []; var self = this; self.chunks = [];
@ -173,89 +214,74 @@ AudioRecorder.prototype.stop = function() {
}); });
}; };
// --- TRANSCRIBE HELPER ---
function transcribeAudio(blob) { function transcribeAudio(blob) {
var formData = new FormData(); var formData = new FormData();
formData.append('audio', blob, 'audio.webm'); formData.append('audio', blob, 'audio.webm');
return fetch('/api/transcribe', { method: 'POST', body: formData }) return fetch('/api/transcribe', {
.then(function(r) { return r.json(); }); method: 'POST',
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
body: formData
}).then(function(r) { return r.json(); });
} }
// --- REFINE HELPER ---
function refineDocument(outputElementId, inputElementId) { function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId).innerText.trim(); var doc = document.getElementById(outputElementId);
var instructions = document.getElementById(inputElementId).value.trim(); var input = document.getElementById(inputElementId);
if (!doc) { showToast('No document to refine', 'error'); return; } if (!doc || !input) return;
if (!instructions) { showToast('Enter instructions for AI', 'error'); return; } var docText = doc.innerText.trim();
var instructions = input.value.trim();
if (!docText) { showToast('No document', 'error'); return; }
if (!instructions) { showToast('Enter instructions', 'error'); return; }
showLoading('Refining...'); showLoading('Refining...');
fetch('/api/refine', { fetch('/api/refine', {
method: 'POST', method: 'POST', headers: getAuthHeaders(),
headers: getAuthHeaders(), body: JSON.stringify({ currentDocument: docText, instructions: instructions, model: getSelectedModel() })
body: JSON.stringify({ currentDocument: doc, instructions: instructions, model: getSelectedModel() })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
if (data.success) { if (data.success) { doc.textContent = data.refined; input.value = ''; showToast('Refined!', 'success'); }
document.getElementById(outputElementId).textContent = data.refined; else showToast(data.error || 'Failed', 'error');
document.getElementById(inputElementId).value = '';
showToast('Refined!', 'success');
} else {
showToast(data.error || 'Refine failed', 'error');
}
}) })
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); }); .catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
} }
// --- SHORTEN HELPER ---
function shortenDocument(outputElementId) { function shortenDocument(outputElementId) {
var doc = document.getElementById(outputElementId).innerText.trim(); var doc = document.getElementById(outputElementId);
if (!doc) { showToast('No document to shorten', 'error'); return; } if (!doc) return;
var text = doc.innerText.trim();
if (!text) { showToast('No document', 'error'); return; }
showLoading('Shortening...'); showLoading('Shortening...');
fetch('/api/shorten', { fetch('/api/shorten', {
method: 'POST', method: 'POST', headers: getAuthHeaders(),
headers: getAuthHeaders(), body: JSON.stringify({ document: text, model: getSelectedModel() })
body: JSON.stringify({ document: doc, model: getSelectedModel() })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
if (data.success) { if (data.success) { doc.textContent = data.shortened; showToast('Shortened!', 'success'); }
document.getElementById(outputElementId).textContent = data.shortened; else showToast(data.error || 'Failed', 'error');
showToast('Shortened!', 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
}) })
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); }); .catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
} }
// --- EXPORT NEXTCLOUD HELPER ---
function exportToNextcloud(elementId, docType) { function exportToNextcloud(elementId, docType) {
var text = document.getElementById(elementId).innerText.trim(); var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText.trim();
if (!text) { showToast('Nothing to export', 'error'); return; } if (!text) { showToast('Nothing to export', 'error'); return; }
var date = new Date().toISOString().split('T')[0];
var filename = docType + '-' + date + '-' + Date.now();
fetch('/api/nextcloud/export', { fetch('/api/nextcloud/export', {
method: 'POST', method: 'POST', headers: getAuthHeaders(),
headers: getAuthHeaders(), body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType })
body: JSON.stringify({ content: text, filename: filename, type: docType })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.success) showToast(data.message, 'success'); if (data.success) showToast(data.message, 'success');
else showToast(data.error || 'Export failed', 'error'); else showToast(data.error || 'Export failed', 'error');
}) })
.catch(function(err) { showToast('Nextcloud not connected. Go to Settings.', 'error'); }); .catch(function() { showToast('Nextcloud not connected', 'error'); });
} }
// --- WEB SPEECH RECOGNITION HELPER ---
function createSpeechRecognition() { function createSpeechRecognition() {
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null; if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition; var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
@ -266,12 +292,9 @@ function createSpeechRecognition() {
return rec; return rec;
} }
console.log('✅ App.js loaded'); // PWA Service Worker
// Register Service Worker for PWA
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function() { navigator.serviceWorker.register('/sw.js').catch(function() {});
console.log('✅ PWA: Service Worker registered');
}).catch(function(err) {
console.log('PWA: SW registration failed', err);
});
} }
console.log('✅ App.js loaded');

View file

@ -1,7 +1,10 @@
// ============================================================ // ============================================================
// AUTH.JS — Login, Register, 2FA, Session Management // AUTH.JS — Login, Register, 2FA, Session Management
// ============================================================ // ============================================================
(function() {
// Wait for DOM to be fully ready
document.addEventListener('DOMContentLoaded', function() {
var authScreen = document.getElementById('auth-screen'); var authScreen = document.getElementById('auth-screen');
var mainApp = document.getElementById('main-app'); var mainApp = document.getElementById('main-app');
var loginForm = document.getElementById('login-form'); var loginForm = document.getElementById('login-form');
@ -12,125 +15,294 @@
var TOKEN_KEY = 'ped_scribe_token'; var TOKEN_KEY = 'ped_scribe_token';
var USER_KEY = 'ped_scribe_user'; var USER_KEY = 'ped_scribe_user';
// Check existing session console.log('[Auth] Initializing...');
var savedToken = localStorage.getItem(TOKEN_KEY); console.log('[Auth] authScreen:', !!authScreen);
var savedUser = localStorage.getItem(USER_KEY); console.log('[Auth] mainApp:', !!mainApp);
console.log('[Auth] loginForm:', !!loginForm);
console.log('[Auth] registerForm:', !!registerForm);
if (savedToken && savedUser) { // Make sure forms are visible/hidden correctly on load
try { showLoginForm();
var user = JSON.parse(savedUser);
showApp(user, savedToken); // Check for saved session
} catch(e) { var savedToken = localStorage.getItem(TOKEN_KEY);
if (savedToken) {
console.log('[Auth] Found saved token, verifying...');
fetch('/api/auth/me', {
headers: { 'Authorization': 'Bearer ' + savedToken }
})
.then(function(r) {
if (r.ok) return r.json();
throw new Error('expired');
})
.then(function(data) {
if (data && data.user) {
console.log('[Auth] Token valid, logging in as:', data.user.email);
enterApp(data.user, savedToken);
} else {
console.log('[Auth] Token invalid');
clearSession();
}
})
.catch(function() {
console.log('[Auth] Token expired or invalid');
clearSession();
});
}
// ---- HELPER FUNCTIONS ----
function showLoginForm() {
if (loginForm) loginForm.style.display = 'block';
if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'none';
}
function showRegisterForm() {
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'block';
if (forgotForm) forgotForm.style.display = 'none';
}
function showForgotForm() {
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'block';
}
function enterApp(user, token) {
window.AUTH_TOKEN = token;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
if (authScreen) authScreen.style.display = 'none';
if (mainApp) {
mainApp.style.display = 'block';
mainApp.className = mainApp.className.replace('hidden', '').trim();
}
if (userName) userName.textContent = user.name || user.email;
// Show admin tab for admins
var adminTabBtn = document.getElementById('admin-tab-btn');
if (adminTabBtn) {
if (user.role === 'admin') {
adminTabBtn.classList.remove('hidden');
} else {
adminTabBtn.classList.add('hidden');
}
}
console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role);
}
function exitApp() {
clearSession();
if (authScreen) authScreen.style.display = 'flex';
if (mainApp) mainApp.style.display = 'none';
var adminTabBtn = document.getElementById('admin-tab-btn');
if (adminTabBtn) adminTabBtn.classList.add('hidden');
showLoginForm();
}
function clearSession() {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
window.AUTH_TOKEN = null;
}
// Global auth header helper
window.getAuthHeaders = function() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '')
};
};
// ---- FORM TOGGLE LINKS ----
document.addEventListener('click', function(e) {
var target = e.target;
// Handle link clicks by ID
if (target.id === 'show-register' || target.closest('#show-register')) {
e.preventDefault();
showRegisterForm();
}
if (target.id === 'show-login' || target.closest('#show-login')) {
e.preventDefault();
showLoginForm();
}
if (target.id === 'show-login-2' || target.closest('#show-login-2')) {
e.preventDefault();
showLoginForm();
}
if (target.id === 'show-forgot' || target.closest('#show-forgot')) {
e.preventDefault();
showForgotForm();
}
// Logout button
if (target.id === 'btn-logout' || target.closest('#btn-logout')) {
e.preventDefault();
exitApp();
showToast('Logged out', 'info');
}
// Settings button
if (target.id === 'btn-settings' || target.closest('#btn-settings')) {
e.preventDefault();
var modal = document.getElementById('settings-modal');
if (modal) {
modal.style.display = 'flex';
modal.className = modal.className.replace('hidden', '').trim();
load2FAStatus();
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
} }
} }
// Form toggles // Close settings
document.getElementById('show-register').addEventListener('click', function(e) { if (target.id === 'close-settings' || target.closest('#close-settings')) {
e.preventDefault(); e.preventDefault();
loginForm.classList.add('hidden'); var modal = document.getElementById('settings-modal');
registerForm.classList.remove('hidden'); if (modal) modal.style.display = 'none';
forgotForm.classList.add('hidden'); }
// Setup 2FA
if (target.id === 'btn-setup-2fa' || target.closest('#btn-setup-2fa')) {
e.preventDefault();
fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
var qr = document.getElementById('2fa-qr');
var secret = document.getElementById('2fa-secret');
var setup = document.getElementById('2fa-setup');
if (qr) qr.src = data.qrCode;
if (secret) secret.textContent = data.secret;
if (setup) { setup.style.display = 'block'; setup.className = setup.className.replace('hidden', '').trim(); }
}
});
}
// Verify 2FA
if (target.id === 'btn-verify-2fa' || target.closest('#btn-verify-2fa')) {
e.preventDefault();
var code = document.getElementById('2fa-verify-code').value;
fetch('/api/auth/verify-2fa', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ code: code })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('2FA enabled!', 'success');
var setup = document.getElementById('2fa-setup');
if (setup) setup.style.display = 'none';
load2FAStatus();
} else {
showToast(data.error || 'Invalid code', 'error');
}
});
}
// Disable 2FA
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
e.preventDefault();
var pw = prompt('Enter password to disable 2FA:');
if (!pw) return;
fetch('/api/auth/disable-2fa', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ password: pw })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('2FA disabled', 'info'); load2FAStatus(); }
else showToast(data.error || 'Failed', 'error');
});
}
}); });
document.getElementById('show-login').addEventListener('click', function(e) { // ---- LOGIN FORM SUBMIT ----
e.preventDefault(); if (loginForm) {
loginForm.classList.remove('hidden');
registerForm.classList.add('hidden');
forgotForm.classList.add('hidden');
});
document.getElementById('show-login-2').addEventListener('click', function(e) {
e.preventDefault();
loginForm.classList.remove('hidden');
registerForm.classList.add('hidden');
forgotForm.classList.add('hidden');
});
document.getElementById('show-forgot').addEventListener('click', function(e) {
e.preventDefault();
loginForm.classList.add('hidden');
registerForm.classList.add('hidden');
forgotForm.classList.remove('hidden');
});
// Login
loginForm.addEventListener('submit', function(e) { loginForm.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
var email = document.getElementById('login-email').value; e.stopPropagation();
var password = document.getElementById('login-password').value;
var totpCode = document.getElementById('login-totp').value;
var email = document.getElementById('login-email').value.trim();
var password = document.getElementById('login-password').value;
var totpEl = document.getElementById('login-totp');
var totpCode = totpEl ? totpEl.value.trim() : '';
if (!email || !password) {
showToast('Enter email and password', 'error');
return false;
}
console.log('[Auth] Attempting login for:', email);
showLoading('Signing in...'); showLoading('Signing in...');
var body = { email: email, password: password };
if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', { fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password, totpCode: totpCode || undefined }) body: JSON.stringify(body)
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
console.log('[Auth] Login response:', JSON.stringify(data).substring(0, 200));
if (data.requires2FA) { if (data.requires2FA) {
document.getElementById('totp-group').classList.remove('hidden'); var grp = document.getElementById('totp-group');
if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
showToast('Enter your 2FA code', 'info'); showToast('Enter your 2FA code', 'info');
return; return;
} }
if (data.success) {
localStorage.setItem(TOKEN_KEY, data.token); if (data.needsVerification) {
localStorage.setItem(USER_KEY, JSON.stringify(data.user)); showToast('Verify your email first. Check inbox.', 'error');
showApp(data.user, data.token); return;
showToast('Welcome back, ' + data.user.name + '!', 'success'); }
if (data.success && data.token && data.user) {
enterApp(data.user, data.token);
showToast('Welcome, ' + data.user.name + '!', 'success');
} else { } else {
showToast(data.error || 'Login failed', 'error'); showToast(data.error || 'Login failed', 'error');
} }
}) })
// Handle email verification needed
if (data.needsVerification) {
showToast(data.message || 'Please verify your email first', 'error');
// Show resend option
var loginLinks = loginForm.querySelector('.auth-links');
if (!document.getElementById('resend-verify-link')) {
var resendLink = document.createElement('a');
resendLink.href = '#';
resendLink.id = 'resend-verify-link';
resendLink.textContent = 'Resend verification email';
resendLink.addEventListener('click', function(e) {
e.preventDefault();
var email = document.getElementById('login-email').value;
if (!email) { showToast('Enter your email first', 'error'); return; }
fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function(r) { return r.json(); })
.then(function(d) { showToast(d.message || 'Verification email sent!', 'success'); });
});
loginLinks.appendChild(resendLink);
}
return;
}
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
showToast('Error: ' + err.message, 'error'); console.error('[Auth] Login error:', err);
}); showToast('Connection error', 'error');
}); });
// Register return false;
registerForm.addEventListener('submit', function(e) { });
e.preventDefault();
var name = document.getElementById('reg-name').value;
var email = document.getElementById('reg-email').value;
var password = document.getElementById('reg-password').value;
if (password.length < 8) {
showToast('Password must be at least 8 characters', 'error');
return;
} }
// ---- REGISTER FORM SUBMIT ----
if (registerForm) {
registerForm.addEventListener('submit', function(e) {
e.preventDefault();
e.stopPropagation();
var name = document.getElementById('reg-name').value.trim();
var email = document.getElementById('reg-email').value.trim();
var password = document.getElementById('reg-password').value;
if (!name || !email || !password) {
showToast('Fill all fields', 'error');
return false;
}
if (password.length < 8) {
showToast('Password must be 8+ characters', 'error');
return false;
}
console.log('[Auth] Registering:', email);
showLoading('Creating account...'); showLoading('Creating account...');
fetch('/api/auth/register', { fetch('/api/auth/register', {
@ -141,27 +313,38 @@
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
if (data.success) { console.log('[Auth] Register response:', JSON.stringify(data).substring(0, 200));
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user)); if (data.success && data.token && data.user) {
showApp(data.user, data.token); enterApp(data.user, data.token);
showToast('Account created! Welcome, ' + data.user.name, 'success'); showToast(data.message || 'Account created!', 'success');
} else if (data.success && data.needsVerification) {
showToast(data.message || 'Check email to verify', 'success');
showLoginForm();
} else { } else {
showToast(data.error || 'Registration failed', 'error'); showToast(data.error || 'Registration failed', 'error');
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
showToast('Error: ' + err.message, 'error'); console.error('[Auth] Register error:', err);
}); showToast('Connection error', 'error');
}); });
// Forgot password return false;
});
}
// ---- FORGOT PASSWORD SUBMIT ----
if (forgotForm) {
forgotForm.addEventListener('submit', function(e) { forgotForm.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
var email = document.getElementById('forgot-email').value; e.stopPropagation();
showLoading('Sending reset link...'); var email = document.getElementById('forgot-email').value.trim();
if (!email) { showToast('Enter email', 'error'); return false; }
showLoading('Sending...');
fetch('/api/auth/forgot-password', { fetch('/api/auth/forgot-password', {
method: 'POST', method: 'POST',
@ -171,124 +354,51 @@
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
showToast(data.message || 'If account exists, reset email sent', 'success'); showToast(data.message || 'Check your email', 'success');
loginForm.classList.remove('hidden'); showLoginForm();
forgotForm.classList.add('hidden');
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
showToast('Error: ' + err.message, 'error'); showToast('Error', 'error');
});
}); });
// Logout return false;
document.getElementById('btn-logout').addEventListener('click', function() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
authScreen.classList.remove('hidden');
mainApp.classList.add('hidden');
showToast('Logged out', 'info');
}); });
function showApp(user, token) {
authScreen.classList.add('hidden');
mainApp.classList.remove('hidden');
if (userName) userName.textContent = user.name || user.email;
window.AUTH_TOKEN = token;
} }
// Helper: add auth header to all API calls // ---- 2FA STATUS ----
window.getAuthHeaders = function() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '')
};
};
// Settings modal
document.getElementById('btn-settings').addEventListener('click', function() {
document.getElementById('settings-modal').classList.remove('hidden');
load2FAStatus();
loadNextcloudStatus();
});
document.getElementById('close-settings').addEventListener('click', function() {
document.getElementById('settings-modal').classList.add('hidden');
});
// 2FA Setup
function load2FAStatus() { function load2FAStatus() {
fetch('/api/auth/me', { headers: getAuthHeaders() }) fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.user) { if (!data || !data.user) return;
var status = document.getElementById('2fa-status'); var status = document.getElementById('2fa-status');
var setupBtn = document.getElementById('btn-setup-2fa'); var setupBtn = document.getElementById('btn-setup-2fa');
var disableBtn = document.getElementById('btn-disable-2fa'); var disableBtn = document.getElementById('btn-disable-2fa');
if (data.user.totp_enabled) { if (data.user.totp_enabled) {
status.textContent = 'Status: ✅ Enabled'; if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; }
status.style.color = '#10b981'; if (setupBtn) setupBtn.style.display = 'none';
setupBtn.classList.add('hidden'); if (disableBtn) disableBtn.style.display = 'inline-flex';
disableBtn.classList.remove('hidden');
} else { } else {
status.textContent = 'Status: ❌ Not enabled'; if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
status.style.color = '#ef4444'; if (setupBtn) setupBtn.style.display = 'inline-flex';
setupBtn.classList.remove('hidden'); if (disableBtn) disableBtn.style.display = 'none';
disableBtn.classList.add('hidden');
} }
}
});
}
document.getElementById('btn-setup-2fa').addEventListener('click', function() {
fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
document.getElementById('2fa-qr').src = data.qrCode;
document.getElementById('2fa-secret').textContent = data.secret;
document.getElementById('2fa-setup').classList.remove('hidden');
}
});
});
document.getElementById('btn-verify-2fa').addEventListener('click', function() {
var code = document.getElementById('2fa-verify-code').value;
fetch('/api/auth/verify-2fa', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ code: code })
}) })
.catch(function() {});
}
// Check registration status
fetch('/api/auth/registration-status')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.success) { if (!data.registrationEnabled) {
showToast('2FA enabled!', 'success'); var link = document.getElementById('show-register');
document.getElementById('2fa-setup').classList.add('hidden'); if (link) link.style.display = 'none';
load2FAStatus();
} else {
showToast(data.error || 'Invalid code', 'error');
} }
});
});
document.getElementById('btn-disable-2fa').addEventListener('click', function() {
var pw = prompt('Enter your password to disable 2FA:');
if (!pw) return;
fetch('/api/auth/disable-2fa', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: pw })
}) })
.then(function(r) { return r.json(); }) .catch(function() {});
.then(function(data) {
if (data.success) {
showToast('2FA disabled', 'info');
load2FAStatus();
} else {
showToast(data.error || 'Failed', 'error');
}
});
});
console.log('✅ Auth module loaded'); console.log('[Auth] ✅ Module ready');
})(); });

View file

@ -22,7 +22,7 @@
'<input type="text" class="visit-specialist" placeholder="Specialist name">' + '<input type="text" class="visit-specialist" placeholder="Specialist name">' +
'<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"></div>' + '<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"></div>' +
'<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>' + '<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>' +
'<input type="text" class="visit-labs" placeholder="Labs from this visit (optional)"></div>'; '<textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea></div>';
visitsContainer.appendChild(card); visitsContainer.appendChild(card);
card.querySelector('.remove-visit-btn').addEventListener('click', function() { card.remove(); }); card.querySelector('.remove-visit-btn').addEventListener('click', function() { card.remove(); });
}); });
@ -34,7 +34,7 @@
addLabBtn.addEventListener('click', function() { addLabBtn.addEventListener('click', function() {
var entry = document.createElement('div'); var entry = document.createElement('div');
entry.className = 'lab-entry'; entry.className = 'lab-entry';
entry.innerHTML = '<input type="date" class="lab-date"><input type="text" class="lab-values" placeholder="Lab results...">' + entry.innerHTML = '<input type="date" class="lab-date"><textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>'; '<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
labsContainer.appendChild(entry); labsContainer.appendChild(entry);
}); });

View file

@ -39,7 +39,7 @@
addLabBtn.addEventListener('click', function() { addLabBtn.addEventListener('click', function() {
var entry = document.createElement('div'); var entry = document.createElement('div');
entry.className = 'lab-entry'; entry.className = 'lab-entry';
entry.innerHTML = '<input type="date" class="lab-date"><input type="text" class="lab-values" placeholder="Lab results...">' + entry.innerHTML = '<input type="date" class="lab-date"><textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>'; '<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
labsContainer.appendChild(entry); labsContainer.appendChild(entry);
}); });

View file

@ -1,37 +1,19 @@
var CACHE_NAME = 'pedscribe-v2'; // Minimal service worker — pass everything through, cache nothing
var urlsToCache = [ self.addEventListener('install', function() {
'/', self.skipWaiting();
'/css/styles.css', });
'/js/app.js',
'/js/auth.js',
'/js/milestonesData.js',
'/js/milestones.js',
'/js/liveEncounter.js',
'/js/voiceDictation.js',
'/js/hospitalCourse.js',
'/js/chartReview.js',
'/js/soap.js',
'/js/nextcloud.js',
'/manifest.json'
];
self.addEventListener('install', function(event) { self.addEventListener('activate', function(event) {
// Clear all old caches
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) { caches.keys().then(function(names) {
return cache.addAll(urlsToCache); return Promise.all(
names.map(function(name) { return caches.delete(name); })
);
}) })
); );
}); });
self.addEventListener('fetch', function(event) { self.addEventListener('fetch', function(event) {
// Network first for API calls, cache first for static assets
if (event.request.url.includes('/api/')) {
event.respondWith(fetch(event.request)); event.respondWith(fetch(event.request));
} else {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
}
}); });

View file

@ -6,32 +6,22 @@ const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
const http = require('http'); const http = require('http');
const loggingMiddleware = require('./src/middleware/logging');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
// Security app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
}));
app.use(cors()); app.use(cors());
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
app.use('/api/', rateLimit({ windowMs: 60000, max: 60, message: { error: 'Too many requests' } }));
// Rate limiting app.use(loggingMiddleware);
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 60,
message: { error: 'Too many requests, slow down' }
});
app.use('/api/', apiLimiter);
// Static files (login page accessible without auth)
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
// Routes // Routes
app.use('/api/auth', require('./src/routes/auth')); app.use('/api/auth', require('./src/routes/auth'));
app.use('/api/admin', require('./src/routes/admin'));
app.use('/api', require('./src/routes/transcribe')); app.use('/api', require('./src/routes/transcribe'));
app.use('/api', require('./src/routes/hpi')); app.use('/api', require('./src/routes/hpi'));
app.use('/api', require('./src/routes/hospitalCourse')); app.use('/api', require('./src/routes/hospitalCourse'));
@ -41,31 +31,32 @@ app.use('/api', require('./src/routes/soap'));
app.use('/api', require('./src/routes/tts')); app.use('/api', require('./src/routes/tts'));
app.use('/api', require('./src/routes/nextcloud')); app.use('/api', require('./src/routes/nextcloud'));
app.use('/api', require('./src/routes/refine')); app.use('/api', require('./src/routes/refine'));
// Models endpoint app.use('/api', require('./src/routes/logs'));
const { AVAILABLE_MODELS } = require('./src/utils/models');
app.get('/api/models', (req, res) => res.json({ models: AVAILABLE_MODELS }));
// Health const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
app.get('/api/models', (req, res) => res.json({ models: getAvailableModels(), provider: modelsProvider }));
const { activeProvider } = require('./src/utils/ai');
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ res.json({
status: 'running', status: 'running', version: '2.1.0', provider: activeProvider,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing', openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured',
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing' whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing'
}); });
}); });
// SPA fallback app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(''); console.log('');
console.log('=========================================='); console.log('==========================================');
console.log('🏥 PEDIATRIC AI SCRIBE v2.0'); console.log('🏥 PEDIATRIC AI SCRIBE v2.1');
console.log('=========================================='); console.log('==========================================');
console.log(`🌐 http://localhost:${PORT}`); console.log('🌐 http://localhost:' + PORT);
console.log('🤖 Provider: ' + activeProvider);
console.log('=========================================='); console.log('==========================================');
}); });

View file

@ -1,47 +1,173 @@
const Database = require('better-sqlite3'); const { Pool } = require('pg');
const fs = require('fs');
const path = require('path'); const path = require('path');
const DB_PATH = path.join(__dirname, '../../data/scribe.db'); const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe';
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(` const pool = new Pool({
connectionString: DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
pool.query('SELECT NOW()')
.then(function() { console.log('✅ PostgreSQL: connected'); })
.catch(function(err) { console.error('❌ PostgreSQL: connection failed:', err.message); });
async function initDatabase() {
var client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
email_verified INTEGER DEFAULT 0, role TEXT DEFAULT 'user',
email_verified BOOLEAN DEFAULT false,
verify_token TEXT, verify_token TEXT,
verify_expires INTEGER, verify_expires BIGINT,
totp_secret TEXT, totp_secret TEXT,
totp_enabled INTEGER DEFAULT 0, totp_enabled BOOLEAN DEFAULT false,
passkey_credentials TEXT, passkey_credentials TEXT,
nextcloud_url TEXT, nextcloud_url TEXT,
nextcloud_user TEXT, nextcloud_user TEXT,
nextcloud_token TEXT, nextcloud_token TEXT,
nextcloud_folder TEXT DEFAULT '/PediatricScribe', nextcloud_folder TEXT DEFAULT '/PediatricScribe',
reset_token TEXT, reset_token TEXT,
reset_expires INTEGER, reset_expires BIGINT,
created_at TEXT DEFAULT (datetime('now')), disabled BOOLEAN DEFAULT false,
updated_at TEXT DEFAULT (datetime('now')) created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL, action TEXT NOT NULL,
category TEXT DEFAULT 'general',
details TEXT, details TEXT,
ip_address TEXT, ip_address TEXT,
timestamp TEXT DEFAULT (datetime('now')) user_agent TEXT,
model_used TEXT,
tokens_used INTEGER,
duration_ms INTEGER,
status TEXT DEFAULT 'success',
timestamp TIMESTAMPTZ DEFAULT NOW()
); );
`);
// Add columns if upgrading from v1 CREATE TABLE IF NOT EXISTS api_log (
try { db.exec("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0"); } catch(e) {} id SERIAL PRIMARY KEY,
try { db.exec("ALTER TABLE users ADD COLUMN verify_token TEXT"); } catch(e) {} user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
try { db.exec("ALTER TABLE users ADD COLUMN verify_expires INTEGER"); } catch(e) {} endpoint TEXT NOT NULL,
try { db.exec("ALTER TABLE users ADD COLUMN nextcloud_folder TEXT DEFAULT '/PediatricScribe'"); } catch(e) {} method TEXT,
status_code INTEGER,
request_size INTEGER,
response_size INTEGER,
model_used TEXT,
tokens_input INTEGER,
tokens_output INTEGER,
cost_estimate REAL,
duration_ms INTEGER,
ip_address TEXT,
error TEXT,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS access_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
success BOOLEAN DEFAULT true,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_api_user ON api_log(user_id);
CREATE INDEX IF NOT EXISTS idx_api_ts ON api_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_access_ts ON access_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
`);
// Add columns if upgrading
var migrations = [
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false"
];
for (var i = 0; i < migrations.length; i++) {
try { await client.query(migrations[i]); } catch(e) {}
}
// Initialize default settings
await client.query(`
INSERT INTO app_settings (key, value) VALUES ('registration_enabled', 'true')
ON CONFLICT (key) DO NOTHING
`);
console.log('✅ Database tables: ready');
} catch (err) {
console.error('❌ Database init error:', err.message);
} finally {
client.release();
}
}
initDatabase();
// Query helpers
var db = {
get: async function(sql, params) {
var result = await pool.query(convertPlaceholders(sql), params);
return result.rows[0] || null;
},
all: async function(sql, params) {
var result = await pool.query(convertPlaceholders(sql), params);
return result.rows;
},
run: async function(sql, params) {
var pgSql = convertPlaceholders(sql);
if (sql.trim().toUpperCase().startsWith('INSERT') && pgSql.toUpperCase().indexOf('RETURNING') === -1) {
pgSql += ' RETURNING id';
}
var result = await pool.query(pgSql, params);
return {
lastInsertRowid: result.rows[0] ? result.rows[0].id : null,
changes: result.rowCount
};
},
query: async function(sql, params) {
return pool.query(convertPlaceholders(sql), params);
},
// Settings helpers
getSetting: async function(key) {
var row = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
return row.rows[0] ? row.rows[0].value : null;
},
setSetting: async function(key, value) {
await pool.query(
'INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
[key, value]
);
},
pool: pool
};
function convertPlaceholders(sql) {
var index = 0;
return sql.replace(/\?/g, function() {
index++;
return '$' + index;
});
}
console.log('✅ Database initialized');
module.exports = db; module.exports = db;

View file

@ -3,11 +3,10 @@ const db = require('../db/database');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
function authMiddleware(req, res, next) { async function authMiddleware(req, res, next) {
// Get token from header or cookie var token = null;
let token = null;
const authHeader = req.headers.authorization; var authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) { if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7); token = authHeader.substring(7);
} else if (req.cookies && req.cookies.token) { } else if (req.cookies && req.cookies.token) {
@ -19,13 +18,17 @@ function authMiddleware(req, res, next) {
} }
try { try {
const decoded = jwt.verify(token, JWT_SECRET); var decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare('SELECT id, email, name, totp_enabled FROM users WHERE id = ?').get(decoded.userId); var user = await db.get('SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?', [decoded.userId]);
if (!user) { if (!user) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });
} }
if (user.disabled) {
return res.status(403).json({ error: 'Account disabled. Contact administrator.' });
}
req.user = user; req.user = user;
next(); next();
} catch (err) { } catch (err) {
@ -33,12 +36,12 @@ function authMiddleware(req, res, next) {
} }
} }
function optionalAuth(req, res, next) { // Admin-only middleware — use after authMiddleware
try { async function adminMiddleware(req, res, next) {
authMiddleware(req, res, next); if (!req.user || req.user.role !== 'admin') {
} catch (e) { return res.status(403).json({ error: 'Admin access required' });
next();
} }
next();
} }
module.exports = { authMiddleware, optionalAuth, JWT_SECRET }; module.exports = { authMiddleware, adminMiddleware, JWT_SECRET };

46
src/middleware/logging.js Normal file
View file

@ -0,0 +1,46 @@
// ============================================================
// LOGGING MIDDLEWARE — Logs all API calls
// ============================================================
var logger = require('../utils/logger');
function loggingMiddleware(req, res, next) {
var startTime = Date.now();
// Capture original json method
var originalJson = res.json.bind(res);
res.json = function(data) {
var duration = Date.now() - startTime;
// Log API calls (skip static, health, models)
if (req.path.startsWith('/api/') &&
req.path !== '/api/health' &&
req.path !== '/api/models' &&
req.method !== 'GET') {
var userId = req.user ? req.user.id : null;
var responseStr = '';
try { responseStr = JSON.stringify(data); } catch(e) { responseStr = ''; }
logger.apiCall(userId, req.path, {
method: req.method,
statusCode: res.statusCode,
requestSize: parseInt(req.headers['content-length'] || 0),
responseSize: responseStr.length,
model: (data && data.model) || (req.body && req.body.model) || null,
tokensInput: (data && data.usage && data.usage.prompt_tokens) ? data.usage.prompt_tokens : 0,
tokensOutput: (data && data.usage && data.usage.completion_tokens) ? data.usage.completion_tokens : 0,
duration: duration,
ip: req.ip || (req.connection && req.connection.remoteAddress) || null,
error: (data && data.error) || null
});
}
return originalJson(data);
};
next();
}
module.exports = loggingMiddleware;

241
src/routes/admin.js Normal file
View file

@ -0,0 +1,241 @@
// ============================================================
// ADMIN ROUTES — User management, settings
// ============================================================
var express = require('express');
var router = express.Router();
var bcrypt = require('bcryptjs');
var db = require('../db/database');
var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
// All admin routes require auth + admin role
router.use(authMiddleware);
router.use(adminMiddleware);
// ============================================================
// GET ALL USERS
// ============================================================
router.get('/users', async function(req, res) {
try {
var users = await db.all(
'SELECT id, email, name, role, email_verified, totp_enabled, disabled, created_at FROM users ORDER BY created_at DESC',
[]
);
res.json({ success: true, users: users });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// GET SINGLE USER DETAILS
// ============================================================
router.get('/users/:id', async function(req, res) {
try {
var user = await db.get(
'SELECT id, email, name, role, email_verified, totp_enabled, disabled, nextcloud_url, created_at, updated_at FROM users WHERE id = ?',
[req.params.id]
);
if (!user) return res.status(404).json({ error: 'User not found' });
// Get user stats
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log WHERE user_id = ?', [req.params.id]);
var lastLogin = await db.get("SELECT timestamp FROM audit_log WHERE user_id = ? AND action = 'login' ORDER BY timestamp DESC LIMIT 1", [req.params.id]);
user.api_calls = apiCount ? parseInt(apiCount.count) : 0;
user.last_login = lastLogin ? lastLogin.timestamp : null;
res.json({ success: true, user: user });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// VERIFY USER EMAIL (admin override)
// ============================================================
router.post('/users/:id/verify', async function(req, res) {
try {
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).json({ error: 'User not found' });
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
logger.audit(req.user.id, 'admin_verify_user', 'Verified user: ' + user.email, req, { category: 'admin' });
res.json({ success: true, message: user.email + ' verified' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// DISABLE USER
// ============================================================
router.post('/users/:id/disable', async function(req, res) {
try {
var userId = parseInt(req.params.id);
// Prevent disabling yourself
if (userId === req.user.id) {
return res.status(400).json({ error: 'Cannot disable your own account' });
}
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [userId]);
if (!user) return res.status(404).json({ error: 'User not found' });
await db.run('UPDATE users SET disabled = true WHERE id = ?', [userId]);
logger.audit(req.user.id, 'admin_disable_user', 'Disabled user: ' + user.email, req, { category: 'admin' });
res.json({ success: true, message: user.email + ' disabled' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// ENABLE USER
// ============================================================
router.post('/users/:id/enable', async function(req, res) {
try {
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).json({ error: 'User not found' });
await db.run('UPDATE users SET disabled = false WHERE id = ?', [user.id]);
logger.audit(req.user.id, 'admin_enable_user', 'Enabled user: ' + user.email, req, { category: 'admin' });
res.json({ success: true, message: user.email + ' enabled' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// SET USER ROLE (admin/user)
// ============================================================
router.post('/users/:id/role', async function(req, res) {
try {
var userId = parseInt(req.params.id);
var { role } = req.body;
if (!role || (role !== 'admin' && role !== 'user')) {
return res.status(400).json({ error: 'Role must be "admin" or "user"' });
}
// Prevent removing your own admin
if (userId === req.user.id && role !== 'admin') {
return res.status(400).json({ error: 'Cannot remove your own admin role' });
}
var user = await db.get('SELECT id, email FROM users WHERE id = ?', [userId]);
if (!user) return res.status(404).json({ error: 'User not found' });
await db.run('UPDATE users SET role = ? WHERE id = ?', [role, userId]);
logger.audit(req.user.id, 'admin_change_role', 'Set ' + user.email + ' role to ' + role, req, { category: 'admin' });
res.json({ success: true, message: user.email + ' is now ' + role });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// DELETE USER
// ============================================================
router.delete('/users/:id', async function(req, res) {
try {
var userId = parseInt(req.params.id);
if (userId === req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [userId]);
if (!user) return res.status(404).json({ error: 'User not found' });
// Delete user (audit/api logs keep user_id as NULL due to ON DELETE SET NULL)
await db.run('DELETE FROM users WHERE id = ?', [userId]);
logger.audit(req.user.id, 'admin_delete_user', 'Deleted user: ' + user.email + ' (' + user.name + ')', req, { category: 'admin' });
res.json({ success: true, message: user.email + ' deleted' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// RESET USER PASSWORD (admin override)
// ============================================================
router.post('/users/:id/reset-password', async function(req, res) {
try {
var { newPassword } = req.body;
if (!newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
var user = await db.get('SELECT id, email FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).json({ error: 'User not found' });
var hash = await bcrypt.hash(newPassword, 12);
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
logger.audit(req.user.id, 'admin_reset_password', 'Reset password for: ' + user.email, req, { category: 'admin' });
res.json({ success: true, message: 'Password reset for ' + user.email });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// TOGGLE REGISTRATION
// ============================================================
router.post('/settings/registration', async function(req, res) {
try {
var { enabled } = req.body;
var value = enabled ? 'true' : 'false';
await db.setSetting('registration_enabled', value);
logger.audit(req.user.id, 'admin_toggle_registration', 'Registration ' + (enabled ? 'enabled' : 'disabled'), req, { category: 'admin' });
res.json({ success: true, registrationEnabled: enabled, message: 'Registration ' + (enabled ? 'enabled' : 'disabled') });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// GET APP SETTINGS
// ============================================================
router.get('/settings', async function(req, res) {
try {
var regEnabled = await db.getSetting('registration_enabled');
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []);
var todayApiCount = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []);
res.json({
success: true,
settings: {
registrationEnabled: regEnabled !== 'false'
},
stats: {
totalUsers: userCount ? parseInt(userCount.count) : 0,
totalApiCalls: apiCount ? parseInt(apiCount.count) : 0,
todayApiCalls: todayApiCount ? parseInt(todayApiCount.count) : 0
}
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// GET ALL LOGS (admin view)
// ============================================================
router.get('/logs/all', async function(req, res) {
try {
var limit = parseInt(req.query.limit) || 100;
var category = req.query.category;
var sql = 'SELECT a.*, u.email as user_email, u.name as user_name FROM audit_log a LEFT JOIN users u ON a.user_id = u.id';
var params = [];
if (category) {
sql += ' WHERE a.category = ?';
params.push(category);
}
sql += ' ORDER BY a.timestamp DESC LIMIT ?';
params.push(limit);
var logs = await db.all(sql, params);
res.json({ success: true, logs: logs });
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;

View file

@ -8,33 +8,21 @@ const crypto = require('crypto');
const db = require('../db/database'); const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
// ============================================================ // Email helper
// EMAIL HELPER
// ============================================================
async function sendEmail(to, subject, html) { async function sendEmail(to, subject, html) {
if (!process.env.SMTP_HOST) { if (!process.env.SMTP_HOST) {
console.log('[Email] SMTP not configured. Would send to:', to); console.log('[Email] SMTP not configured. Would send to:', to);
console.log('[Email] Subject:', subject);
return false; return false;
} }
try { try {
const nodemailer = require('nodemailer'); var nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({ var transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587, port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true', secure: process.env.SMTP_SECURE === 'true',
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
}); });
await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: to, subject: subject, html: html });
await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: to,
subject: subject,
html: html
});
console.log('[Email] Sent to:', to);
return true; return true;
} catch (err) { } catch (err) {
console.error('[Email] Failed:', err.message); console.error('[Email] Failed:', err.message);
@ -43,60 +31,62 @@ async function sendEmail(to, subject, html) {
} }
// ============================================================ // ============================================================
// REGISTER (with email verification) // REGISTER (checks if registration is enabled)
// ============================================================ // ============================================================
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
const { email, password, name } = req.body; // Check if registration is enabled
var regEnabled = await db.getSetting('registration_enabled');
if (regEnabled === 'false') {
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
}
var { email, password, name } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' }); if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' }); if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase()); var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) return res.status(400).json({ error: 'Email already registered' }); if (existing) return res.status(400).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(password, 12); var hash = await bcrypt.hash(password, 12);
const verifyToken = crypto.randomBytes(32).toString('hex'); var verifyToken = crypto.randomBytes(32).toString('hex');
const verifyExpires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
const result = db.prepare( // First user becomes admin automatically
'INSERT INTO users (email, password, name, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, 0)' var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
).run(email.toLowerCase(), hash, name, verifyToken, verifyExpires); var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
// Send verification email var result = await db.run(
const verifyUrl = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`; 'INSERT INTO users (email, password, name, role, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, ?, false)',
[email.toLowerCase(), hash, name, role, verifyToken, verifyExpires]
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
`<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px;">
<h2 style="color:#2563eb;">Welcome to Pediatric AI Scribe!</h2>
<p>Hi ${name},</p>
<p>Please verify your email address by clicking the button below:</p>
<p style="text-align:center;margin:30px 0;">
<a href="${verifyUrl}" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a>
</p>
<p style="color:#6b7280;font-size:13px;">Or copy this link: ${verifyUrl}</p>
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours.</p>
</div>`
); );
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(result.lastInsertRowid, 'register', req.ip); var userId = result.lastInsertRowid;
// If SMTP not configured, auto-verify (for development) var verifyUrl = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken;
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
'<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px;">' +
'<h2 style="color:#2563eb;">Welcome to Pediatric AI Scribe!</h2>' +
'<p>Hi ' + name + ',</p><p>Verify your email:</p>' +
'<p style="text-align:center;margin:30px 0;"><a href="' + verifyUrl + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a></p>' +
'<p style="color:#6b7280;font-size:13px;">Expires in 24 hours.</p></div>'
);
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
// Auto-verify if no SMTP
if (!process.env.SMTP_HOST) { if (!process.env.SMTP_HOST) {
db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL WHERE id = ?').run(result.lastInsertRowid); await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' }); var token = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' });
return res.json({ return res.json({
success: true, success: true, token: token,
token, user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true },
user: { id: result.lastInsertRowid, email: email.toLowerCase(), name, email_verified: 1 }, message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).'
message: 'Account created (auto-verified, SMTP not configured)'
}); });
} }
res.json({ res.json({ success: true, needsVerification: true, message: 'Check your email for verification link.' + (role === 'admin' ? ' You are the first user and have admin privileges.' : '') });
success: true,
needsVerification: true,
message: 'Account created! Check your email for verification link.'
});
} catch (err) { } catch (err) {
console.error('Register error:', err); console.error('Register error:', err);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -106,177 +96,149 @@ router.post('/register', async (req, res) => {
// ============================================================ // ============================================================
// VERIFY EMAIL // VERIFY EMAIL
// ============================================================ // ============================================================
router.get('/verify-email', (req, res) => { router.get('/verify-email', async (req, res) => {
const { token } = req.query; try {
if (!token) return res.status(400).send('Missing verification token'); var token = req.query.token;
if (!token) return res.status(400).send('Missing token');
const user = db.prepare('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?').get(token, Date.now()); var user = await db.get('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?', [token, Date.now()]);
if (!user) { if (!user) {
return res.send(` return res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#ef4444;">Invalid or Expired Link</h2><p><a href="' + (process.env.APP_URL || '/') + '">Go to app</a></p></body></html>');
<html><body style="font-family:sans-serif;text-align:center;padding:60px;">
<h2 style="color:#ef4444;"> Invalid or Expired Link</h2>
<p>This verification link is invalid or has expired.</p>
<p><a href="${process.env.APP_URL || '/'}">Go to app</a></p>
</body></html>
`);
} }
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(user.id); await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
db.prepare('INSERT INTO audit_log (user_id, action) VALUES (?, ?)').run(user.id, 'email_verified'); var safeName = user.name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#10b981;">✅ Email Verified!</h2><p>Welcome, ' + safeName + '!</p><p><a href="' + (process.env.APP_URL || '/') + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;">Open App</a></p></body></html>');
res.send(` } catch (err) { res.status(500).send('Error: ' + err.message); }
<html><body style="font-family:sans-serif;text-align:center;padding:60px;">
<h2 style="color:#10b981;"> Email Verified!</h2>
<p>Welcome, ${user.name}! Your account is now active.</p>
<p><a href="${process.env.APP_URL || '/'}" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Open App</a></p>
</body></html>
`);
}); });
// ============================================================
// RESEND VERIFICATION // RESEND VERIFICATION
// ============================================================
router.post('/resend-verification', async (req, res) => { router.post('/resend-verification', async (req, res) => {
try { try {
const { email } = req.body; var user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
const user = db.prepare('SELECT id, name, email_verified FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) return res.json({ success: true, message: 'If account exists, verification email sent' }); if (!user) return res.json({ success: true, message: 'If account exists, verification email sent' });
if (user.email_verified) return res.json({ success: true, message: 'Email already verified. You can log in.' }); if (user.email_verified) return res.json({ success: true, message: 'Already verified.' });
var verifyToken = crypto.randomBytes(32).toString('hex');
const verifyToken = crypto.randomBytes(32).toString('hex'); await db.run('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?', [verifyToken, Date.now() + 86400000, user.id]);
const verifyExpires = Date.now() + 24 * 60 * 60 * 1000; var verifyUrl = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken;
await sendEmail(req.body.email, 'Verify your email', '<p>Click to verify: <a href="' + verifyUrl + '">' + verifyUrl + '</a></p>');
db.prepare('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?').run(verifyToken, verifyExpires, user.id);
const verifyUrl = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
`<p>Hi ${user.name},</p><p>Click to verify: <a href="${verifyUrl}">${verifyUrl}</a></p><p>Expires in 24 hours.</p>`
);
res.json({ success: true, message: 'Verification email sent' }); res.json({ success: true, message: 'Verification email sent' });
} catch (err) { } catch (err) { res.status(500).json({ error: err.message }); }
res.status(500).json({ error: err.message });
}
}); });
// ============================================================ // ============================================================
// LOGIN (checks email verification) // LOGIN (checks disabled status)
// ============================================================ // ============================================================
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
const { email, password, totpCode } = req.body; var { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()); var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) return res.status(401).json({ error: 'Invalid credentials' }); if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.password); if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']);
return res.status(403).json({ error: 'Account disabled. Contact an administrator.' });
}
var valid = await bcrypt.compare(password, user.password);
if (!valid) { if (!valid) {
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(user.id, 'login_failed', req.ip); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]);
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
// Check email verification
if (!user.email_verified) { if (!user.email_verified) {
return res.status(403).json({ return res.status(403).json({ error: 'Email not verified', needsVerification: true });
error: 'Email not verified',
needsVerification: true,
message: 'Please check your email for the verification link.'
});
} }
// Check 2FA
if (user.totp_enabled) { if (user.totp_enabled) {
if (!totpCode) return res.json({ requires2FA: true }); if (!totpCode) return res.json({ requires2FA: true });
const verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 }); var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' }); if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' });
} }
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(user.id, 'login', req.ip); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
res.json({ res.json({
success: true, success: true, token: token,
token, user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
user: { id: user.id, email: user.email, name: user.name, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
}); });
} catch (err) { } catch (err) { res.status(500).json({ error: err.message }); }
res.status(500).json({ error: err.message });
}
}); });
// ============================================================ // 2FA
// 2FA SETUP / VERIFY / DISABLE (same as before)
// ============================================================
router.post('/setup-2fa', authMiddleware, async (req, res) => { router.post('/setup-2fa', authMiddleware, async (req, res) => {
try { try {
const secret = speakeasy.generateSecret({ name: `PedScribe (${req.user.email})`, issuer: 'Pediatric AI Scribe' }); var secret = speakeasy.generateSecret({ name: 'PedScribe (' + req.user.email + ')', issuer: 'Pediatric AI Scribe' });
db.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run(secret.base32, req.user.id); await db.run('UPDATE users SET totp_secret = ? WHERE id = ?', [secret.base32, req.user.id]);
const qrUrl = await QRCode.toDataURL(secret.otpauth_url); var qrUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ success: true, secret: secret.base32, qrCode: qrUrl }); res.json({ success: true, secret: secret.base32, qrCode: qrUrl });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
router.post('/verify-2fa', authMiddleware, async (req, res) => { router.post('/verify-2fa', authMiddleware, async (req, res) => {
try { try {
const user = db.prepare('SELECT totp_secret FROM users WHERE id = ?').get(req.user.id); var user = await db.get('SELECT totp_secret FROM users WHERE id = ?', [req.user.id]);
const verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 }); var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
if (!verified) return res.status(400).json({ error: 'Invalid code' }); if (!verified) return res.status(400).json({ error: 'Invalid code' });
db.prepare('UPDATE users SET totp_enabled = 1 WHERE id = ?').run(req.user.id); await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]);
res.json({ success: true }); res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
router.post('/disable-2fa', authMiddleware, async (req, res) => { router.post('/disable-2fa', authMiddleware, async (req, res) => {
try { try {
const user = db.prepare('SELECT password FROM users WHERE id = ?').get(req.user.id); var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
const valid = await bcrypt.compare(req.body.password, user.password); var valid = await bcrypt.compare(req.body.password, user.password);
if (!valid) return res.status(401).json({ error: 'Wrong password' }); if (!valid) return res.status(401).json({ error: 'Wrong password' });
db.prepare('UPDATE users SET totp_enabled = 0, totp_secret = NULL WHERE id = ?').run(req.user.id); await db.run('UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = ?', [req.user.id]);
res.json({ success: true }); res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// ============================================================ // Password reset
// FORGOT / RESET PASSWORD (same as before)
// ============================================================
router.post('/forgot-password', async (req, res) => { router.post('/forgot-password', async (req, res) => {
try { try {
const { email } = req.body; var user = await db.get('SELECT id, name FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
const user = db.prepare('SELECT id, name FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' }); if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
var token = crypto.randomBytes(32).toString('hex');
const token = crypto.randomBytes(32).toString('hex'); await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]);
db.prepare('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?').run(token, Date.now() + 3600000, user.id); await sendEmail(req.body.email, 'Password Reset', '<p>Reset: <a href="' + (process.env.APP_URL || 'http://localhost:3000') + '/reset-password?token=' + token + '">Click here</a></p>');
await sendEmail(email, 'Password Reset - Pediatric AI Scribe',
`<p>Hi ${user.name},</p><p>Reset your password: <a href="${process.env.APP_URL}/reset-password?token=${token}">Click here</a></p><p>Expires in 1 hour.</p>`
);
res.json({ success: true, message: 'If account exists, reset email sent' }); res.json({ success: true, message: 'If account exists, reset email sent' });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
router.post('/reset-password', async (req, res) => { router.post('/reset-password', async (req, res) => {
try { try {
const { token, newPassword } = req.body; var { token, newPassword } = req.body;
if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' }); if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' });
const user = db.prepare('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?').get(token, Date.now()); var user = await db.get('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?', [token, Date.now()]);
if (!user) return res.status(400).json({ error: 'Invalid or expired token' }); if (!user) return res.status(400).json({ error: 'Invalid or expired token' });
const hash = await bcrypt.hash(newPassword, 12); var hash = await bcrypt.hash(newPassword, 12);
db.prepare('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?').run(hash, user.id); await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
res.json({ success: true }); res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// ============================================================ // Get current user
// GET CURRENT USER router.get('/me', authMiddleware, async (req, res) => {
// ============================================================ try {
router.get('/me', authMiddleware, (req, res) => { var user = await db.get(
const user = db.prepare('SELECT id, email, name, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?').get(req.user.id); 'SELECT id, email, name, role, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?',
res.json({ user }); [req.user.id]
);
res.json({ user: user });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Check if registration is enabled (public endpoint)
router.get('/registration-status', async (req, res) => {
try {
var enabled = await db.getSetting('registration_enabled');
res.json({ registrationEnabled: enabled !== 'false' });
} catch (err) { res.json({ registrationEnabled: true }); }
}); });
module.exports = router; module.exports = router;

View file

@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { callAI } = require('../utils/ai'); const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts'); const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
// Generate chart review // Generate chart review
router.post('/generate-chart-review', async (req, res) => { router.post('/generate-chart-review', authMiddleware, async (req, res) => {
try { try {
const { const {
type, // 'outpatient' | 'subspecialty' | 'ed' type, // 'outpatient' | 'subspecialty' | 'ed'

View file

@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { callAI } = require('../utils/ai'); const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts'); const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
// Generate hospital course // Generate hospital course
router.post('/generate-hospital-course', async (req, res) => { router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
try { try {
const { const {
notes, // Array of { date, type, content } notes, // Array of { date, type, content }
@ -96,7 +97,7 @@ router.post('/generate-hospital-course', async (req, res) => {
}); });
// Ask for clarification/missing info // Ask for clarification/missing info
router.post('/hospital-course-clarify', async (req, res) => { router.post('/hospital-course-clarify', authMiddleware, async (req, res) => {
try { try {
const { currentDraft, notes } = req.body; const { currentDraft, notes } = req.body;
@ -112,7 +113,7 @@ router.post('/hospital-course-clarify', async (req, res) => {
}); });
// Add discharge day info // Add discharge day info
router.post('/hospital-course-update', async (req, res) => { router.post('/hospital-course-update', authMiddleware, async (req, res) => {
try { try {
const { currentCourse, updates, model, instructions } = req.body; const { currentCourse, updates, model, instructions } = req.body;
if (!currentCourse) return res.status(400).json({ error: 'No current course' }); if (!currentCourse) return res.status(400).json({ error: 'No current course' });

View file

@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { callAI } = require('../utils/ai'); const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts'); const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
// HPI from encounter // HPI from encounter
router.post('/generate-hpi-encounter', async (req, res) => { router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
try { try {
const { transcript, patientAge, patientGender, model, setting } = req.body; const { transcript, patientAge, patientGender, model, setting } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' }); if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' });
@ -23,7 +24,7 @@ router.post('/generate-hpi-encounter', async (req, res) => {
}); });
// HPI from dictation // HPI from dictation
router.post('/generate-hpi-dictation', async (req, res) => { router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => {
try { try {
const { transcript, patientAge, patientGender, model, setting } = req.body; const { transcript, patientAge, patientGender, model, setting } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' }); if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' });

35
src/routes/logs.js Normal file
View file

@ -0,0 +1,35 @@
var express = require('express');
var router = express.Router();
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.get('/logs/usage', authMiddleware, async function(req, res) {
try {
var days = parseInt(req.query.days) || 30;
var summary = await logger.getUsageSummary(req.user.id, days);
res.json({ success: true, summary: summary, period: days + ' days' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.get('/logs/audit', authMiddleware, async function(req, res) {
try {
var logs = await logger.getAuditLogs(req.user.id, parseInt(req.query.limit) || 50);
res.json({ success: true, logs: logs });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.get('/logs/api', authMiddleware, async function(req, res) {
try {
var logs = await logger.getApiLogs(req.user.id, parseInt(req.query.limit) || 50);
res.json({ success: true, logs: logs });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.get('/logs/access', authMiddleware, async function(req, res) {
try {
var logs = await logger.getAccessLogs(req.user.id, parseInt(req.query.limit) || 50);
res.json({ success: true, logs: logs });
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;

View file

@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { callAI } = require('../utils/ai'); const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts'); const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
// Narrative format // Narrative format
router.post('/generate-milestone-narrative', async (req, res) => { router.post('/generate-milestone-narrative', authMiddleware, async (req, res) => {
try { try {
const { milestones, ageGroup, patientAge, patientGender, model, format } = req.body; const { milestones, ageGroup, patientAge, patientGender, model, format } = req.body;
if (!milestones) return res.status(400).json({ error: 'No milestones' }); if (!milestones) return res.status(400).json({ error: 'No milestones' });
@ -47,7 +48,7 @@ router.post('/generate-milestone-narrative', async (req, res) => {
}); });
// 3-sentence summary // 3-sentence summary
router.post('/generate-milestone-summary', async (req, res) => { router.post('/generate-milestone-summary', authMiddleware, async (req, res) => {
try { try {
const { narrative, ageGroup, patientAge, patientGender, model } = req.body; const { narrative, ageGroup, patientAge, patientGender, model } = req.body;
if (!narrative) return res.status(400).json({ error: 'No narrative' }); if (!narrative) return res.status(400).json({ error: 'No narrative' });

View file

@ -1,139 +1,66 @@
const express = require('express'); var express = require('express');
const router = express.Router(); var router = express.Router();
const axios = require('axios'); var axios = require('axios');
const { authMiddleware } = require('../middleware/auth'); var { authMiddleware } = require('../middleware/auth');
const db = require('../db/database'); var db = require('../db/database');
// Connect router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
router.post('/nextcloud/connect', authMiddleware, async (req, res) => {
try { try {
const { nextcloudUrl, username, appPassword, folder } = req.body; var { nextcloudUrl, username, appPassword, folder } = req.body;
if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' }); if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' });
var cleanUrl = nextcloudUrl.replace(/\/+$/, ''); var cleanUrl = nextcloudUrl.replace(/\/+$/, '');
var targetFolder = (folder || '/PediatricScribe').replace(/\/+$/, ''); var targetFolder = (folder || '/PediatricScribe').replace(/\/+$/, '');
// Test connection
try { try {
await axios({ method: 'PROPFIND', url: `${cleanUrl}/remote.php/dav/files/${username}/`, auth: { username, password: appPassword }, headers: { Depth: '0' } }); await axios({ method: 'PROPFIND', url: cleanUrl + '/remote.php/dav/files/' + username + '/', auth: { username: username, password: appPassword }, headers: { Depth: '0' } });
} catch (e) { } catch (e) {
return res.status(400).json({ error: 'Cannot connect. Check URL, username, and app password.' }); return res.status(400).json({ error: 'Cannot connect. Check credentials.' });
} }
// Create folder
try {
var parts = targetFolder.split('/').filter(Boolean); var parts = targetFolder.split('/').filter(Boolean);
var current = ''; var current = '';
for (var i = 0; i < parts.length; i++) { for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i]; current += '/' + parts[i];
try { try { await axios({ method: 'MKCOL', url: cleanUrl + '/remote.php/dav/files/' + username + current + '/', auth: { username: username, password: appPassword } }); } catch (e) {}
await axios({ method: 'MKCOL', url: `${cleanUrl}/remote.php/dav/files/${username}${current}/`, auth: { username, password: appPassword } });
} catch (e) { /* exists */ }
} }
} catch (e) { /* folder exists */ }
db.prepare('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?') await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
.run(cleanUrl, username, appPassword, targetFolder, req.user.id); [cleanUrl, username, appPassword, targetFolder, req.user.id]);
res.json({ success: true, message: `Connected! Files saved to ${targetFolder}/` }); res.json({ success: true, message: 'Connected! Files saved to ' + targetFolder + '/' });
} catch (err) { } catch (err) { res.status(500).json({ error: err.message }); }
res.status(500).json({ error: err.message });
}
}); });
// List folders (for picker) router.post('/nextcloud/export', authMiddleware, async function(req, res) {
router.get('/nextcloud/folders', authMiddleware, async (req, res) => {
try { try {
var user = db.prepare('SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?').get(req.user.id); var { content, filename, type } = req.body;
if (!user.nextcloud_url) return res.status(400).json({ error: 'Not connected' }); var user = await db.get('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?', [req.user.id]);
if (!user || !user.nextcloud_url) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
var path = req.query.path || '/';
var url = `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${path}`;
var response = await axios({
method: 'PROPFIND',
url: url,
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
headers: { Depth: '1' }
});
// Parse WebDAV XML response for folders
var folders = [];
var matches = response.data.match(/<d:href>([^<]+)<\/d:href>/g) || [];
matches.forEach(function(match) {
var href = match.replace(/<\/?d:href>/g, '');
if (href.endsWith('/')) {
var decoded = decodeURIComponent(href);
var parts = decoded.split('/').filter(Boolean);
var name = parts[parts.length - 1];
if (name && name !== user.nextcloud_user) {
folders.push({ name: name, path: decoded.replace(/.*\/remote\.php\/dav\/files\/[^/]+/, '') });
}
}
});
res.json({ success: true, folders: folders, currentPath: path });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Export with subfolder support
router.post('/nextcloud/export', authMiddleware, async (req, res) => {
try {
const { content, filename, type, subfolder } = req.body;
var user = db.prepare('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?').get(req.user.id);
if (!user.nextcloud_url || !user.nextcloud_token) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
var baseFolder = user.nextcloud_folder || '/PediatricScribe'; var baseFolder = user.nextcloud_folder || '/PediatricScribe';
var targetPath = baseFolder;
// Create date subfolder automatically
var today = new Date().toISOString().split('T')[0]; var today = new Date().toISOString().split('T')[0];
targetPath += '/' + today; var targetPath = baseFolder + '/' + today;
// Additional subfolder if specified
if (subfolder) targetPath += '/' + subfolder.replace(/[^a-zA-Z0-9-_ ]/g, '');
// Create folder path
var parts = targetPath.split('/').filter(Boolean); var parts = targetPath.split('/').filter(Boolean);
var current = ''; var current = '';
for (var i = 0; i < parts.length; i++) { for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i]; current += '/' + parts[i];
try { try { await axios({ method: 'MKCOL', url: user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + current + '/', auth: { username: user.nextcloud_user, password: user.nextcloud_token } }); } catch (e) {}
await axios({ method: 'MKCOL', url: `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${current}/`, auth: { username: user.nextcloud_user, password: user.nextcloud_token } });
} catch (e) { /* exists */ }
} }
var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-'); var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_'); var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
var filePath = `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${targetPath}/${safeName}.txt`; var filePath = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + targetPath + '/' + safeName + '.txt';
await axios({ await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: user.nextcloud_token }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
method: 'PUT',
url: filePath,
data: content,
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
res.json({ success: true, message: `Saved to ${targetPath}/${safeName}.txt`, path: `${targetPath}/${safeName}.txt` }); res.json({ success: true, message: 'Saved to ' + targetPath + '/' + safeName + '.txt' });
} catch (err) { } catch (err) { res.status(500).json({ error: err.message }); }
res.status(500).json({ error: err.message });
}
}); });
// Update folder setting router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
router.post('/nextcloud/set-folder', authMiddleware, (req, res) => { await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
const { folder } = req.body;
db.prepare('UPDATE users SET nextcloud_folder = ? WHERE id = ?').run(folder || '/PediatricScribe', req.user.id);
res.json({ success: true });
});
// Disconnect
router.post('/nextcloud/disconnect', authMiddleware, (req, res) => {
db.prepare('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?').run(req.user.id);
res.json({ success: true }); res.json({ success: true });
}); });

View file

@ -1,19 +1,27 @@
const express = require('express'); // ============================================================
const router = express.Router(); // REFINE ROUTES — Modify, shorten, clarify any document
const { callAI } = require('../utils/ai'); // ============================================================
const PROMPTS = require('../utils/prompts');
// Refine/modify any generated document var express = require('express');
router.post('/refine', async (req, res) => { var router = express.Router();
var { callAI } = require('../utils/ai');
var PROMPTS = require('../utils/prompts');
var { authMiddleware } = require('../middleware/auth');
// Refine/modify document with instructions
router.post('/refine', authMiddleware, async function(req, res) {
try { try {
const { currentDocument, instructions, model } = req.body; var currentDocument = req.body.currentDocument;
var instructions = req.body.instructions;
var model = req.body.model;
if (!currentDocument) return res.status(400).json({ error: 'No document to refine' }); if (!currentDocument) return res.status(400).json({ error: 'No document to refine' });
if (!instructions) return res.status(400).json({ error: 'No instructions provided' }); if (!instructions) return res.status(400).json({ error: 'No instructions provided' });
const result = await callAI([ var result = await callAI([
{ role: 'system', content: PROMPTS.refine }, { role: 'system', content: PROMPTS.refine },
{ role: 'user', content: `CURRENT DOCUMENT:\n${currentDocument}\n\nUSER INSTRUCTIONS:\n${instructions}` } { role: 'user', content: 'CURRENT DOCUMENT:\n' + currentDocument + '\n\nUSER INSTRUCTIONS:\n' + instructions }
], { model, maxTokens: 6000 }); ], { model: model, maxTokens: 6000 });
res.json({ success: true, refined: result.content, model: result.model }); res.json({ success: true, refined: result.content, model: result.model });
} catch (err) { } catch (err) {
@ -21,16 +29,18 @@ router.post('/refine', async (req, res) => {
} }
}); });
// Shorten any document // Shorten document
router.post('/shorten', async (req, res) => { router.post('/shorten', authMiddleware, async function(req, res) {
try { try {
const { document, model } = req.body; var document = req.body.document;
var model = req.body.model;
if (!document) return res.status(400).json({ error: 'No document' }); if (!document) return res.status(400).json({ error: 'No document' });
const result = await callAI([ var result = await callAI([
{ role: 'system', content: PROMPTS.shortenDocument }, { role: 'system', content: PROMPTS.shortenDocument },
{ role: 'user', content: document } { role: 'user', content: document }
], { model }); ], { model: model });
res.json({ success: true, shortened: result.content, model: result.model }); res.json({ success: true, shortened: result.content, model: result.model });
} catch (err) { } catch (err) {
@ -39,14 +49,18 @@ router.post('/shorten', async (req, res) => {
}); });
// Ask AI for clarification questions // Ask AI for clarification questions
router.post('/clarify', async (req, res) => { router.post('/clarify', authMiddleware, async function(req, res) {
try { try {
const { document, context, model } = req.body; var document = req.body.document;
var context = req.body.context;
var model = req.body.model;
const result = await callAI([ if (!document) return res.status(400).json({ error: 'No document' });
var result = await callAI([
{ role: 'system', content: PROMPTS.askClarification }, { role: 'system', content: PROMPTS.askClarification },
{ role: 'user', content: `Document type: ${context || 'medical document'}\n\nDOCUMENT:\n${document}` } { role: 'user', content: 'Document type: ' + (context || 'medical document') + '\n\nDOCUMENT:\n' + document }
], { model, maxTokens: 1000 }); ], { model: model, maxTokens: 1000 });
res.json({ success: true, questions: result.content, model: result.model }); res.json({ success: true, questions: result.content, model: result.model });
} catch (err) { } catch (err) {

View file

@ -2,8 +2,9 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { callAI } = require('../utils/ai'); const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts'); const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
router.post('/generate-soap', async (req, res) => { router.post('/generate-soap', authMiddleware, async (req, res) => {
try { try {
const { transcript, patientAge, patientGender, model, type, additionalInstructions } = req.body; const { transcript, patientAge, patientGender, model, type, additionalInstructions } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' }); if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' });

View file

@ -2,10 +2,11 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const { whisperClient } = require('../utils/ai'); const { whisperClient } = require('../utils/ai');
const { authMiddleware } = require('../middleware/auth');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
router.post('/transcribe', upload.single('audio'), async (req, res) => { router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, res) => {
try { try {
if (!req.file) return res.status(400).json({ error: 'No audio' }); if (!req.file) return res.status(400).json({ error: 'No audio' });
if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured' }); if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured' });

View file

@ -1,8 +1,9 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const axios = require('axios'); const axios = require('axios');
const { authMiddleware } = require('../middleware/auth');
router.post('/text-to-speech', async (req, res) => { router.post('/text-to-speech', authMiddleware, async (req, res) => {
try { try {
if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'Not configured' }); if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'Not configured' });
const response = await axios({ const response = await axios({

View file

@ -1,48 +1,268 @@
const { OpenAI } = require('openai'); // ============================================================
const { DEFAULT_MODEL, FALLBACK_MODEL } = require('./models'); // AI.JS — Multi-provider AI client
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
// ============================================================
const openrouter = new OpenAI({ const { OpenAI } = require('openai');
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId } = require('./models');
const logger = require('./logger');
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
// ============================================================
// OPENROUTER CLIENT (default)
// ============================================================
var openrouter = null;
if (process.env.OPENROUTER_API_KEY) {
openrouter = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1', baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY, apiKey: process.env.OPENROUTER_API_KEY,
defaultHeaders: { defaultHeaders: {
'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000', 'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000',
'X-Title': 'Pediatric AI Scribe' 'X-Title': 'Pediatric AI Scribe'
} }
}); });
console.log('✅ OpenRouter: configured');
}
let whisperClient = null; // ============================================================
// AWS BEDROCK CLIENT (optional, HIPAA compliant)
// ============================================================
var bedrockClient = null;
if (process.env.AWS_BEDROCK_REGION) {
try {
var BedrockModule = require('@aws-sdk/client-bedrock-runtime');
var BedrockRuntimeClient = BedrockModule.BedrockRuntimeClient;
bedrockClient = new BedrockRuntimeClient({
region: process.env.AWS_BEDROCK_REGION || 'us-east-1',
credentials: process.env.AWS_ACCESS_KEY_ID ? {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
} : undefined
});
activeProvider = 'bedrock';
console.log('✅ AWS Bedrock: configured (region: ' + process.env.AWS_BEDROCK_REGION + ')');
} catch (e) {
console.log('⚠️ AWS Bedrock: SDK not installed. Install with: npm install @aws-sdk/client-bedrock-runtime');
console.log('⚠️ Falling back to OpenRouter');
activeProvider = 'openrouter';
}
}
// ============================================================
// AZURE OPENAI CLIENT (optional, HIPAA compliant)
// ============================================================
var azureClient = null;
if (process.env.AZURE_OPENAI_ENDPOINT) {
try {
azureClient = new OpenAI({
apiKey: process.env.AZURE_OPENAI_API_KEY,
baseURL: process.env.AZURE_OPENAI_ENDPOINT.replace(/\/+$/, '') + '/openai/deployments/' + (process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini'),
defaultQuery: { 'api-version': process.env.AZURE_OPENAI_API_VERSION || '2024-08-01-preview' },
defaultHeaders: { 'api-key': process.env.AZURE_OPENAI_API_KEY }
});
activeProvider = 'azure';
console.log('✅ Azure OpenAI: configured (deployment: ' + (process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini') + ')');
} catch (e) {
console.log('⚠️ Azure OpenAI: configuration failed:', e.message);
console.log('⚠️ Falling back to OpenRouter');
activeProvider = 'openrouter';
}
}
// ============================================================
// WHISPER CLIENT (always OpenAI, separate from text AI)
// ============================================================
var whisperClient = null;
if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-')) { if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-')) {
whisperClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); whisperClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
console.log('✅ Whisper: configured'); console.log('✅ Whisper: configured');
} else {
console.log('⚠️ Whisper: not configured (set OPENAI_API_KEY)');
} }
async function callAI(messages, options = {}) { // Force provider from env if explicitly set
const model = options.model || DEFAULT_MODEL; if (process.env.AI_PROVIDER) {
const temperature = options.temperature || 0.3; activeProvider = process.env.AI_PROVIDER;
const maxTokens = options.maxTokens || 4000; }
// Validate: if chosen provider has no client, fall back
if (activeProvider === 'bedrock' && !bedrockClient) {
console.log('⚠️ Bedrock selected but not available. Falling back to OpenRouter.');
activeProvider = 'openrouter';
}
if (activeProvider === 'azure' && !azureClient) {
console.log('⚠️ Azure selected but not available. Falling back to OpenRouter.');
activeProvider = 'openrouter';
}
if (activeProvider === 'openrouter' && !openrouter) {
console.error('❌ OpenRouter selected but OPENROUTER_API_KEY not set!');
}
console.log('🤖 Active AI provider:', activeProvider);
// ============================================================
// CALL OPENROUTER
// ============================================================
async function callOpenRouter(messages, model, temperature, maxTokens) {
if (!openrouter) throw new Error('OpenRouter not configured. Set OPENROUTER_API_KEY in .env');
var completion = await openrouter.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
return {
success: true,
content: completion.choices[0].message.content,
model: model,
provider: 'openrouter',
usage: completion.usage || null
};
}
// ============================================================
// CALL AZURE OPENAI
// ============================================================
async function callAzure(messages, model, temperature, maxTokens) {
if (!azureClient) throw new Error('Azure OpenAI not configured');
var completion = await azureClient.chat.completions.create({
model: process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini',
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
return {
success: true,
content: completion.choices[0].message.content,
model: process.env.AZURE_DEPLOYMENT_NAME || model,
provider: 'azure',
usage: completion.usage || null
};
}
// ============================================================
// CALL AWS BEDROCK
// ============================================================
async function callBedrock(messages, model, temperature, maxTokens) {
if (!bedrockClient) throw new Error('AWS Bedrock not configured');
var BedrockModule = require('@aws-sdk/client-bedrock-runtime');
var InvokeModelCommand = BedrockModule.InvokeModelCommand;
// Map friendly model ID to Bedrock model ID
var modelId = getBedrockModelId(model);
// Separate system message from chat messages
var systemMsg = '';
var chatMessages = [];
messages.forEach(function(m) {
if (m.role === 'system') {
systemMsg += (systemMsg ? '\n' : '') + m.content;
} else {
chatMessages.push({ role: m.role, content: m.content });
}
});
// Build request body (Anthropic Messages API format for Bedrock)
var body = {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: maxTokens,
temperature: temperature,
messages: chatMessages
};
if (systemMsg) body.system = systemMsg;
var command = new InvokeModelCommand({
modelId: modelId,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify(body)
});
var response = await bedrockClient.send(command);
var responseBody = JSON.parse(new TextDecoder().decode(response.body));
return {
success: true,
content: responseBody.content[0].text,
model: modelId,
provider: 'bedrock',
usage: {
prompt_tokens: responseBody.usage ? responseBody.usage.input_tokens : 0,
completion_tokens: responseBody.usage ? responseBody.usage.output_tokens : 0
}
};
}
// ============================================================
// MAIN CALL AI FUNCTION — Routes to correct provider
// ============================================================
async function callAI(messages, options) {
options = options || {};
var model = options.model || DEFAULT_MODEL;
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
var startTime = Date.now();
try { try {
console.log(`[AI] ${model} | ${messages[messages.length - 1].content.substring(0, 60)}...`); var result;
const completion = await openrouter.chat.completions.create({
model, messages, temperature, max_tokens: maxTokens // Route to correct provider
if (activeProvider === 'bedrock' && bedrockClient) {
result = await callBedrock(messages, model, temperature, maxTokens);
} else if (activeProvider === 'azure' && azureClient) {
result = await callAzure(messages, model, temperature, maxTokens);
} else if (openrouter) {
result = await callOpenRouter(messages, model, temperature, maxTokens);
} else {
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, or AZURE_OPENAI_ENDPOINT in .env');
}
var duration = Date.now() - startTime;
logger.info('AI call success', {
provider: activeProvider,
model: result.model,
duration: duration,
outputLength: result.content.length,
tokens: result.usage
}); });
const content = completion.choices[0].message.content;
console.log(`[AI] Done: ${content.length} chars`); result.duration = duration;
return { success: true, content, model, usage: completion.usage }; return result;
} catch (err) { } catch (err) {
console.error(`[AI] ${model} failed: ${err.message}`); duration = Date.now() - startTime;
if (model !== FALLBACK_MODEL) { logger.error('AI call failed', {
try { provider: activeProvider,
const completion = await openrouter.chat.completions.create({ model: model,
model: FALLBACK_MODEL, messages, temperature, max_tokens: maxTokens error: err.message,
duration: duration
}); });
return { success: true, content: completion.choices[0].message.content, model: FALLBACK_MODEL, fallback: true };
// Try fallback model (only on OpenRouter — Bedrock/Azure don't have multiple models easily)
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
try {
var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens);
fallbackResult.fallback = true;
fallbackResult.duration = Date.now() - startTime;
logger.info('Fallback success', { model: FALLBACK_MODEL });
return fallbackResult;
} catch (err2) { } catch (err2) {
throw new Error(`All models failed: ${err2.message}`); logger.error('Fallback also failed', { error: err2.message });
throw new Error('All models failed: ' + err2.message);
} }
} }
throw err; throw err;
} }
} }
module.exports = { callAI, whisperClient, openrouter }; module.exports = { callAI, whisperClient, activeProvider };

106
src/utils/logger.js Normal file
View file

@ -0,0 +1,106 @@
const fs = require('fs');
const path = require('path');
const db = require('../db/database');
var LOG_DIR = path.join(__dirname, '../../data/logs');
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
function estimateCost(model, inputTokens, outputTokens) {
if (!model || !inputTokens) return 0;
var rates = {
'google/gemini-2.5-flash': { input: 0.075, output: 0.30 },
'google/gemini-2.5-pro': { input: 1.25, output: 5.00 },
'deepseek/deepseek-chat-v3-0324': { input: 0.14, output: 0.28 },
'deepseek/deepseek-r1': { input: 0.55, output: 2.19 },
'deepseek/deepseek-r1:free': { input: 0, output: 0 },
'qwen/qwen3-30b-a3b:free': { input: 0, output: 0 },
'anthropic/vendor-model-sonnet-4': { input: 3.00, output: 15.00 },
'anthropic/vendor-model-3-haiku': { input: 0.25, output: 1.25 },
'openai/gpt-4.1-mini': { input: 0.40, output: 1.60 },
'meta-llama/llama-3.3-70b-instruct': { input: 0.10, output: 0.16 }
};
var rate = rates[model] || { input: 0.50, output: 1.00 };
return ((inputTokens / 1000000) * rate.input) + (((outputTokens || 0) / 1000000) * rate.output);
}
var logger = {
audit: function(userId, action, details, req, extra) {
var e = extra || {};
db.run(
'INSERT INTO audit_log (user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
userId || null, action, e.category || 'general',
typeof details === 'string' ? details : JSON.stringify(details),
req ? (req.ip || null) : null,
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
]
).catch(function(err) { console.error('[Logger] Audit failed:', err.message); });
},
apiCall: function(userId, endpoint, data) {
var d = data || {};
var cost = estimateCost(d.model, d.tokensInput, d.tokensOutput);
db.run(
'INSERT INTO api_log (user_id, endpoint, method, status_code, request_size, response_size, model_used, tokens_input, tokens_output, cost_estimate, duration_ms, ip_address, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
userId || null, endpoint, d.method || 'POST', d.statusCode || 200,
d.requestSize || 0, d.responseSize || 0, d.model || null,
d.tokensInput || 0, d.tokensOutput || 0, cost, d.duration || 0,
d.ip || null, d.error || null
]
).catch(function(err) { console.error('[Logger] API log failed:', err.message); });
},
access: function(userId, action, req, success) {
db.run(
'INSERT INTO access_log (user_id, action, ip_address, user_agent, success) VALUES (?, ?, ?, ?, ?)',
[
userId || null, action,
req ? (req.ip || null) : null,
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
success ? true : false
]
).catch(function(err) { console.error('[Logger] Access log failed:', err.message); });
},
file: function(level, message, data) {
try {
var now = new Date();
var date = now.toISOString().split('T')[0];
var logFile = path.join(LOG_DIR, date + '.log');
var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + message;
if (data) line += ' | ' + (typeof data === 'string' ? data : JSON.stringify(data));
line += '\n';
fs.appendFileSync(logFile, line);
if (level === 'error') console.error(line.trim());
} catch (e) {}
},
info: function(msg, data) { this.file('info', msg, data); },
warn: function(msg, data) { this.file('warn', msg, data); },
error: function(msg, data) { this.file('error', msg, data); },
getAuditLogs: function(userId, limit) {
return db.all('SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
},
getApiLogs: function(userId, limit) {
return db.all('SELECT * FROM api_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
},
getAccessLogs: function(userId, limit) {
return db.all('SELECT * FROM access_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
},
getUsageSummary: function(userId, days) {
var since = new Date(Date.now() - (days || 30) * 86400000).toISOString();
return db.all(
'SELECT COUNT(*) as total_calls, SUM(tokens_input) as total_input_tokens, SUM(tokens_output) as total_output_tokens, SUM(cost_estimate) as total_estimated_cost, AVG(duration_ms) as avg_duration_ms, model_used, endpoint FROM api_log WHERE user_id = ? AND timestamp > ? GROUP BY model_used, endpoint ORDER BY total_calls DESC',
[userId, since]
);
}
};
console.log('✅ Logger initialized');
module.exports = logger;

View file

@ -1,35 +1,107 @@
const AVAILABLE_MODELS = [ // ============================================================
// Best value // MODELS.JS — Provider-aware model list
// ============================================================
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
var OPENROUTER_MODELS = [
{ id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' }, { id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' },
{ id: 'google/gemini-2.5-pro', name: 'Gemini Pro 2.5', cost: '~$0.01', tag: 'SMART', category: 'premium' }, { id: 'google/gemini-2.5-pro', name: 'Gemini Pro 2.5', cost: '~$0.01', tag: 'SMART', category: 'premium' },
{ id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking', cost: '~$0.002', tag: 'REASONING', category: 'smart' }, { id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking', cost: '~$0.002', tag: 'REASONING', category: 'smart' },
// DeepSeek
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', cost: '~$0.001', tag: 'CHEAP', category: 'fast' }, { id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', cost: '~$0.001', tag: 'CHEAP', category: 'fast' },
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', cost: '~$0.005', tag: 'REASONING', category: 'smart' }, { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', cost: '~$0.005', tag: 'REASONING', category: 'smart' },
{ id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 Free', cost: 'FREE', tag: 'FREE', category: 'free' }, { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 Free', cost: 'FREE', tag: 'FREE', category: 'free' },
// Qwen
{ id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', cost: '~$0.005', tag: 'SMART', category: 'smart' }, { id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', cost: '~$0.005', tag: 'SMART', category: 'smart' },
{ id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B Free', cost: 'FREE', tag: 'FREE', category: 'free' }, { id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B Free', cost: 'FREE', tag: 'FREE', category: 'free' },
// Meta Llama
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', cost: '~$0.002', tag: 'NEW', category: 'smart' }, { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', cost: '~$0.002', tag: 'NEW', category: 'smart' },
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', cost: '~$0.001', tag: 'GOOD', category: 'fast' }, { id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', cost: '~$0.001', tag: 'GOOD', category: 'fast' },
// Premium
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'PREMIUM', category: 'premium' }, { id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'PREMIUM', category: 'premium' },
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'SMART', category: 'smart' }, { id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'SMART', category: 'smart' },
{ id: 'openai/o4-mini', name: 'o4-mini (Reasoning)', cost: '~$0.01', tag: 'REASONING', category: 'premium' }, { id: 'openai/o4-mini', name: 'o4-mini (Reasoning)', cost: '~$0.01', tag: 'REASONING', category: 'premium' },
{ id: 'anthropic/vendor-model-sonnet-4', name: 'vendor model Sonnet 4', cost: '~$0.015', tag: 'PREMIUM', category: 'premium' }, { id: 'anthropic/vendor-model-sonnet-4', name: 'vendor model Sonnet 4', cost: '~$0.015', tag: 'PREMIUM', category: 'premium' },
{ id: 'anthropic/vendor-model-3.5-sonnet', name: 'vendor model 3.5 Sonnet', cost: '~$0.015', tag: 'MEDICAL', category: 'premium' }, { id: 'anthropic/vendor-model-3.5-sonnet', name: 'vendor model 3.5 Sonnet', cost: '~$0.015', tag: 'MEDICAL', category: 'premium' },
// Mistral
{ id: 'mistralai/mistral-large-2411', name: 'Mistral Large', cost: '~$0.006', tag: 'GOOD', category: 'smart' }, { id: 'mistralai/mistral-large-2411', name: 'Mistral Large', cost: '~$0.006', tag: 'GOOD', category: 'smart' },
{ id: 'mistralai/mistral-small-3.2-24b-instruct:free', name: 'Mistral Small Free', cost: 'FREE', tag: 'FREE', category: 'free' } { id: 'mistralai/mistral-small-3.2-24b-instruct:free', name: 'Mistral Small Free', cost: 'FREE', tag: 'FREE', category: 'free' }
]; ];
const DEFAULT_MODEL = 'google/gemini-2.5-flash'; var BEDROCK_MODELS = [
const FALLBACK_MODEL = 'deepseek/deepseek-chat-v3-0324'; { id: 'anthropic/vendor-model-sonnet-4', name: 'vendor model Sonnet 4', cost: '~$0.015', tag: 'BEST', category: 'premium',
bedrockId: 'anthropic.agent-config-sonnet-4-20250514-v1:0' },
{ id: 'anthropic/vendor-model-3.5-sonnet', name: 'vendor model 3.5 Sonnet', cost: '~$0.015', tag: 'MEDICAL', category: 'premium',
bedrockId: 'anthropic.agent-config-3-5-sonnet-20241022-v2:0' },
{ id: 'anthropic/vendor-model-3-haiku', name: 'vendor model 3 Haiku', cost: '~$0.001', tag: 'CHEAPEST', category: 'fast',
bedrockId: 'anthropic.agent-config-3-haiku-20240307-v1:0' },
{ id: 'meta-llama/llama-3.1-70b', name: 'Llama 3.1 70B', cost: '~$0.003', tag: 'GOOD', category: 'smart',
bedrockId: 'meta.llama3-1-70b-instruct-v1:0' },
{ id: 'meta-llama/llama-3.1-8b', name: 'Llama 3.1 8B', cost: '~$0.001', tag: 'FAST', category: 'fast',
bedrockId: 'meta.llama3-1-8b-instruct-v1:0' },
{ id: 'mistral/mistral-large', name: 'Mistral Large', cost: '~$0.008', tag: 'GOOD', category: 'smart',
bedrockId: 'mistral.mistral-large-2407-v1:0' },
{ id: 'amazon/titan-text-premier', name: 'Amazon Titan Premier', cost: '~$0.002', tag: 'AWS', category: 'fast',
bedrockId: 'amazon.titan-text-premier-v1:0' }
];
module.exports = { AVAILABLE_MODELS, DEFAULT_MODEL, FALLBACK_MODEL }; var AZURE_MODELS = [
{ id: 'gpt-4o', name: 'GPT-4o', cost: '~$0.01', tag: 'BEST', category: 'premium',
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', cost: '~$0.003', tag: 'SMART', category: 'smart',
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' },
{ id: 'gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'NEW', category: 'premium',
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' },
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'VALUE', category: 'smart',
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }
];
function getAvailableModels() {
switch (activeProvider) {
case 'bedrock': return BEDROCK_MODELS;
case 'azure': return AZURE_MODELS;
case 'openrouter':
default: return OPENROUTER_MODELS;
}
}
function getDefaultModel() {
switch (activeProvider) {
case 'bedrock': return 'anthropic/vendor-model-3-haiku';
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
case 'openrouter':
default: return 'google/gemini-2.5-flash';
}
}
function getFallbackModel() {
switch (activeProvider) {
case 'bedrock': return 'meta-llama/llama-3.1-8b';
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
case 'openrouter':
default: return 'deepseek/deepseek-chat-v3-0324';
}
}
function getBedrockModelId(modelId) {
var found = BEDROCK_MODELS.find(function(m) { return m.id === modelId; });
return found ? found.bedrockId : modelId;
}
var AVAILABLE_MODELS = getAvailableModels();
var DEFAULT_MODEL = getDefaultModel();
var FALLBACK_MODEL = getFallbackModel();
console.log('🤖 Provider:', activeProvider);
console.log('🤖 Default model:', DEFAULT_MODEL);
console.log('🤖 Models available:', AVAILABLE_MODELS.length);
module.exports = {
AVAILABLE_MODELS,
DEFAULT_MODEL,
FALLBACK_MODEL,
OPENROUTER_MODELS,
BEDROCK_MODELS,
AZURE_MODELS,
getAvailableModels,
getDefaultModel,
getFallbackModel,
getBedrockModelId,
activeProvider
};