diff --git a/backend/src/index.js b/backend/src/index.js index 44fddab..8eb1a17 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -12,6 +12,8 @@ const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); const authRoutes = require('./routes/auth'); +const triageRoutes = require('./routes/triage'); +const bankidRoutes = require('./routes/bankid'); const fileRoutes = require('./routes/file'); const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); @@ -102,6 +104,8 @@ require('./auth/auth'); app.use(bodyParser.json()); app.use('/api/auth', authRoutes); +app.use('/api/bankid', bankidRoutes); +app.use('/api/triage', triageRoutes); app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); app.enable('trust proxy'); diff --git a/backend/src/routes/bankid.js b/backend/src/routes/bankid.js new file mode 100644 index 0000000..a7258d5 --- /dev/null +++ b/backend/src/routes/bankid.js @@ -0,0 +1,87 @@ +const express = require('express'); +const router = express.Router(); +const helpers = require('../helpers'); +const UsersDBApi = require('../db/api/users'); +const PatientsDBApi = require('../db/api/patients'); +const wrapAsync = require('../helpers').wrapAsync; + +const sessions = new Map(); + +router.post('/initiate', wrapAsync(async (req, res) => { + const { personalNumber } = req.body; + if (!personalNumber) { + return res.status(400).send({ message: 'Personal number is required' }); + } + const orderRef = Math.random().toString(36).substring(7); + sessions.set(orderRef, { personalNumber, status: 'pending', createdAt: Date.now() }); + res.status(200).send({ orderRef, message: 'Auth initiated. Please sign in your BankID app.' }); +})); + +router.post('/status', wrapAsync(async (req, res) => { + const { orderRef } = req.body; + const session = sessions.get(orderRef); + if (!session) { + return res.status(404).send({ message: 'Session not found' }); + } + + // Simulate progress + const elapsed = Date.now() - session.createdAt; + if (elapsed > 1000) { // Reduced from 3000 to 1000 for faster feel + session.status = 'complete'; + } + + res.status(200).send({ status: session.status }); +})); + +router.post('/verify', wrapAsync(async (req, res) => { + const { orderRef } = req.body; + const session = sessions.get(orderRef); + if (!session || session.status !== 'complete') { + return res.status(400).send({ message: 'Auth not complete' }); + } + + const { personalNumber } = session; + + // 1. Try to find patient by personal_number + const patient = await PatientsDBApi.findBy({ personal_number: personalNumber }); + let user; + + if (patient && patient.user) { + user = await UsersDBApi.findBy({ id: patient.user.id }); + } else { + // 2. Try to find user by mock email as fallback + user = await UsersDBApi.findBy({ email: `${personalNumber}@bankid.sim` }); + } + + if (!user) { + // Create mock user if not found + user = await UsersDBApi.createFromAuth({ + firstName: 'Patient', + lastName: personalNumber, + email: `${personalNumber}@bankid.sim`, + password: 'mock_password', + phoneNumber: personalNumber, + }); + + // Also create a patient record if it didn't exist + if (!patient) { + await PatientsDBApi.create({ + display_name: `Patient ${personalNumber}`, + personal_number: personalNumber, + user: user.id + }, { currentUser: user }); + } + } + + const token = helpers.jwtSign({ + user: { + id: user.id, + email: user.email + } + }); + + sessions.delete(orderRef); + res.status(200).send({ token, user }); +})); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/triage.js b/backend/src/routes/triage.js new file mode 100644 index 0000000..46cb5bb --- /dev/null +++ b/backend/src/routes/triage.js @@ -0,0 +1,17 @@ +const express = require('express'); +const router = express.Router(); +const TriageService = require('../services/triage'); +const { wrapAsync } = require('../helpers'); +const passport = require('passport'); + +router.post('/process', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { symptomText, fullName, phoneNumber } = req.body; + if (!symptomText) { + return res.status(400).send({ message: 'Symptom text is required' }); + } + + const result = await TriageService.performTriage(symptomText, req.currentUser, { fullName, phoneNumber }); + res.status(200).send(result); +})); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/visits.js b/backend/src/routes/visits.js index 158a192..4d6e47f 100644 --- a/backend/src/routes/visits.js +++ b/backend/src/routes/visits.js @@ -1,4 +1,3 @@ - const express = require('express'); const VisitsService = require('../services/visits'); @@ -19,6 +18,10 @@ const { router.use(checkCrudPermissions('visits')); +router.post('/:id/call', wrapAsync(async (req, res) => { + const result = await VisitsService.callPatient(req.params.id, req.currentUser); + res.status(200).send(result); +})); /** * @swagger @@ -445,4 +448,4 @@ router.get('/:id', wrapAsync(async (req, res) => { router.use('/', require('../helpers').commonErrorHandler); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/triage.js b/backend/src/services/triage.js new file mode 100644 index 0000000..3fbcf8c --- /dev/null +++ b/backend/src/services/triage.js @@ -0,0 +1,181 @@ +const db = require('../db/models'); +const { LocalAIApi } = require('../ai/LocalAIApi'); + +class TriageService { + static async performTriage(symptomText, currentUser, data = {}) { + const { fullName, phoneNumber, extraData = {} } = data; + const transaction = await db.sequelize.transaction(); + try { + // 1. Update User info if provided + if (fullName || phoneNumber) { + const names = (fullName || "").split(" "); + const firstName = names[0] || currentUser.firstName; + const lastName = names.slice(1).join(" ") || currentUser.lastName; + + await db.users.update({ + firstName, + lastName, + phoneNumber: phoneNumber || currentUser.phoneNumber + }, { + where: { id: currentUser.id }, + transaction + }); + } + + // 2. Get or create patient record for user + let patient = await db.patients.findOne({ where: { userId: currentUser.id }, transaction }); + + const patientData = { + display_name: fullName || (currentUser.firstName + ' ' + currentUser.lastName), + userId: currentUser.id + }; + + if (!patient) { + patientData.personal_number = currentUser.email.split('@')[0]; + patient = await db.patients.create(patientData, { transaction }); + } else { + await patient.update({ display_name: patientData.display_name }, { transaction }); + } + + // 3. AI Triage with detailed info + const fullContext = { + symptoms: symptomText, + ...extraData + }; + + let triageResult = await this.callAI(fullContext); + let aiFallback = false; + + if (!triageResult) { + triageResult = this.ruleBasedFallback(fullContext); + aiFallback = true; + } + + const levelStr = triageResult.level.toString(); + + // 4. Create Visit + const visit = await db.visits.create({ + patientId: patient.id, + symptom_text: symptomText, + triage_level: levelStr, + priority_score: triageResult.score, + status: 'waiting', + ai_fallback: aiFallback, + created_ts: new Date() + }, { transaction }); + + // 5. Create AI Analysis record + await db.ai_analyses.create({ + visitId: visit.id, + raw_output: JSON.stringify(triageResult), + analysis_type: 'triage', + result_level: levelStr + }, { transaction }); + + // 6. Calculate dynamic wait time + const waitTime = await this.calculateWaitTime(triageResult.level, transaction); + + // 7. Add to Queue + await db.queue.create({ + visitId: visit.id, + triage_level: levelStr, + waiting_since: new Date(), + label: `Patient ${patient.display_name}`, + estimated_wait_minutes: waitTime + }, { transaction }); + + await transaction.commit(); + return { visit, triageResult }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async callAI(context) { + try { + const prompt = ` + You are a highly experienced medical triage specialist in a Swedish Emergency Room. + Analyze the following patient data to determine the triage level (1-5). + + LEVEL DEFINITIONS: + 1: Red - Life-threatening. Immediate intervention required. + 2: Orange - Very urgent. High risk of deterioration. + 3: Yellow - Urgent. Stable but needs investigation. + 4: Green - Standard. Non-urgent, but requires care. + 5: Blue - Non-urgent. Could be handled by primary care. + + PATIENT DATA: + - Main Symptoms: ${context.symptoms} + - Pain Level (0-10): ${context.painLevel} + - Onset: ${context.onset} + - Selected Categories: ${context.selectedSymptoms?.join(', ')} + - Medical History: ${context.medicalHistory} + - Medications: ${context.medications} + - Allergies: ${context.allergies} + + Return ONLY a JSON object: + { + "level": number (1-5), + "reasoning": "Short Swedish explanation (max 30 words)", + "score": number (0-100, where 100 is most urgent), + "advice": "Short Swedish advice for the patient while waiting" + } + `; + + const resp = await LocalAIApi.createResponse({ + input: [ + { role: "system", content: "You are a professional medical triage AI." }, + { role: "user", content: prompt } + ] + }, { poll_interval: 2, poll_timeout: 45 }); + + if (resp.success) { + return LocalAIApi.decodeJsonFromResponse(resp); + } + } catch (err) { + console.error("AI Triage failed:", err); + } + return null; + } + + static ruleBasedFallback(context) { + const text = (context.symptoms || "").toLowerCase(); + const pain = context.painLevel || 0; + + // Critical signs + if (text.includes('medvetslös') || text.includes('andas inte') || text.includes('stopp')) { + return { level: 1, reasoning: "Livshotande tillstånd identifierat (Fallback)", score: 100 }; + } + + if (text.includes('bröst') || text.includes('andas') || text.includes('hjärta') || pain >= 9) { + return { level: 2, reasoning: "Potentiellt allvarligt (Fallback)", score: 85 }; + } + + if (text.includes('blöd') || text.includes('fraktur') || pain >= 7) { + return { level: 3, reasoning: "Urgent behov av vård (Fallback)", score: 65 }; + } + + if (pain >= 4 || text.includes('feber')) { + return { level: 4, reasoning: "Stabil men behöver bedömning (Fallback)", score: 45 }; + } + + return { level: 5, reasoning: "Mindre brådskande (Fallback)", score: 25 }; + } + + static async calculateWaitTime(level, transaction) { + const base = { 1: 0, 2: 20, 3: 90, 4: 180, 5: 300 }; + + const count = await db.queue.count({ + where: { + triage_level: { [db.Sequelize.Op.lte]: level.toString() }, + called: false + }, + transaction + }); + + return (base[level] || 120) + (count * 15); + } +} + +module.exports = TriageService; \ No newline at end of file diff --git a/backend/src/services/visits.js b/backend/src/services/visits.js index 2ec90f6..16afb69 100644 --- a/backend/src/services/visits.js +++ b/backend/src/services/visits.js @@ -7,10 +7,6 @@ const axios = require('axios'); const config = require('../config'); const stream = require('stream'); - - - - module.exports = class VisitsService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); @@ -30,6 +26,37 @@ module.exports = class VisitsService { } }; + static async callPatient(id, currentUser) { + console.log('Calling patient with visit ID:', id); + const transaction = await db.sequelize.transaction(); + try { + if (!id || id === 'undefined') { + throw new ValidationError('visitNotFound'); + } + + const visit = await db.visits.findByPk(id, { transaction }); + if (!visit) { + console.error('Visit not found for ID:', id); + throw new ValidationError('visitNotFound'); + } + + // Update visit status + await visit.update({ status: 'in_consultation' }, { transaction }); + + // Update queue entry + await db.queue.update( + { called: true }, + { where: { visitId: id }, transaction } + ); + + await transaction.commit(); + return visit; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async bulkImport(req, res, sendInvitationEmails = true, host) { const transaction = await db.sequelize.transaction(); @@ -131,8 +158,4 @@ module.exports = class VisitsService { throw error; } } - - -}; - - +}; \ No newline at end of file diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index d70f2ee..f3ef662 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,7 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, - + { + href: '/staff/queue', + label: 'Patientkö (Personal)', + icon: icon.mdiAccountMultiple, + permissions: 'READ_QUEUE' + }, { href: '/users/users-list', label: 'Users', @@ -128,4 +133,4 @@ const menuAside: MenuAsideItem[] = [ }, ] -export default menuAside +export default menuAside; \ No newline at end of file diff --git a/frontend/src/pages/bankid-login.tsx b/frontend/src/pages/bankid-login.tsx new file mode 100644 index 0000000..4c7ad05 --- /dev/null +++ b/frontend/src/pages/bankid-login.tsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import FormField from '../components/FormField'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import { getPageTitle } from '../config'; +import LoadingSpinner from '../components/LoadingSpinner'; +import { mdiFingerprint, mdiShieldCheck, mdiAlertCircle, mdiClockFast } from '@mdi/js'; +import BaseIcon from '../components/BaseIcon'; + +export default function BankIDLogin() { + const [personalNumber, setPersonalNumber] = useState(''); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState('idle'); // idle, initiated, completed, error, timeout + const [orderRef, setOrderRef] = useState(null); + const [securityCode, setSecurityCode] = useState(''); + const [countdown, setCountdown] = useState(60); + const router = useRouter(); + + const handleInitiate = async () => { + // Basic validation for Swedish personal number + const pn = personalNumber.replace(/[^0-9]/g, ''); + if (pn.length !== 10 && pn.length !== 12) { + alert('Vänligen ange ett giltigt personnummer (10 eller 12 siffror)'); + return; + } + + setLoading(true); + try { + const response = await axios.post('/bankid/initiate', { personalNumber }); + setOrderRef(response.data.orderRef); + setSecurityCode(Math.floor(1000 + Math.random() * 9000).toString()); + setStatus('initiated'); + setCountdown(60); + } catch (error) { + console.error(error); + setStatus('error'); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + setStatus('idle'); + setOrderRef(null); + }; + + useEffect(() => { + let interval; + if (status === 'initiated' && orderRef) { + interval = setInterval(async () => { + setCountdown(prev => { + if (prev <= 1) { + setStatus('timeout'); + clearInterval(interval); + return 0; + } + return prev - 1; + }); + + try { + const response = await axios.post('/bankid/status', { orderRef }); + if (response.data.status === 'complete') { + setStatus('completed'); + clearInterval(interval); + } + } catch (error) { + console.error(error); + setStatus('error'); + clearInterval(interval); + } + }, 1000); + } + return () => clearInterval(interval); + }, [status, orderRef]); + + useEffect(() => { + if (status === 'completed' && orderRef) { + const verify = async () => { + try { + const response = await axios.post('/bankid/verify', { orderRef }); + const { token, user } = response.data; + localStorage.setItem('token', token); + localStorage.setItem('user', JSON.stringify(user)); + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + router.push('/patient/new-visit'); + } catch (error) { + console.error(error); + setStatus('error'); + } + }; + verify(); + } + }, [status, orderRef, router]); + + return ( + <> +
+Trygg och säker identifiering
++ Identifiera dig med BankID för att hämta din journal och påbörja din triagering. +
++ Kontrollera att säkerhetskoden i appen stämmer överens: +
++ Inloggningen kunde inte slutföras. Vänligen kontrollera din anslutning och försök igen. +
++ Snabb & Säker Digital Triage +
+This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-For guides and documentation please check - your local README.md and the Flatlogic documentation
-© 2026 {title}. All rights reserved
- - Privacy Policy - -+ Vår AI-baserade plattform hjälper dig att snabbt få rätt vård. Genom att triagera dig hemma minskar vi trycket på akuten och ger dig en estimerad köplats direkt. +
+Över 10,000 patienter hjälpta denna månad
+Status
+Patient Triage
+"Symtom: Tryck över bröstet..."
+Prioritet: Akut (Nivå 2)
+Vårt system är utvecklat för att optimera patientflödet och säkerställa att de mest akuta fallen får hjälp först.
+{feature.desc}
++ Säkerställ att vi kan nå dig vid behov. Uppgifterna hämtas från din profil men kan ändras här. +
+Förklara kortfattat vad som har hänt och hur du känner dig.
+Välj de symtom som stämmer in på dig.
+Denna information hjälper oss att göra en säkrare bedömning.
+
+
+ Genom att klicka på "Skicka in" godkänner du att din information analyseras av systemet för att kunna prioritera ditt besök. Vid livsfara, ring 112. +
+Besöket kunde inte hittas. Vänligen kontakta personal.
++ Vänligen bege dig till Receptionsdisk 1 +
+Vi väntar på dig. Din legitimation behöver visas upp vid ankomst.
+Estimerad väntetid
+Din Status
++ {aiData?.reasoning || 'Dina symtom analyseras för att ge dig rätt vårdnivå.'} +
++ {aiData?.advice || 'Vila och håll dig i närheten. Kontakta personal om ditt tillstånd försämras.'} +
+Symtom beskrivna:
++ "{visit.symptom_text}" +
++ Kontakta omedelbart personalen på plats om dina symtom förvärras, om du får svårt att andas eller känner tryck över bröstet. +
+
+
+ "{item.visit?.symptom_text}" +
+
+
+ {aiData?.reasoning || 'Ingen AI-analys tillgänglig.'} +
+