Merge branch 'main' into feature/settings-page

This commit is contained in:
Matt DiMeglio 2026-02-07 12:04:11 -05:00
commit 3a23f60488
22 changed files with 387 additions and 75 deletions

View file

@ -90,5 +90,5 @@ jobs:
steps:
- name: Deploy to Coolify
run: |
curl --request GET '${{ secrets.COOLIFY_WEBHOOK_API }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'
curl '${{ secrets.COOLIFY_WEBHOOK_API }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'

View file

@ -90,5 +90,5 @@ jobs:
steps:
- name: Deploy to Coolify
run: |
curl --request GET '${{ secrets.COOLIFY_WEBHOOK_API }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'
curl '${{ secrets.COOLIFY_WEBHOOK_API }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'

View file

@ -16,7 +16,7 @@ on:
- web/**
jobs:
determine-workflow:
runs-on: 'ubuntu-latest'
runs-on: ['self-hosted','pi']
outputs:
workflow_type: ${{ steps.workflow.outputs.workflow_type }}
workflow_envs: ${{ steps.workflow.outputs.workflow_envs }}

View file

@ -90,5 +90,5 @@ jobs:
steps:
- name: Deploy to Coolify
run: |
curl --request GET '${{ secrets.COOLIFY_WEBHOOK_WEB }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'
curl '${{ secrets.COOLIFY_WEBHOOK_WEB }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'

View file

@ -90,5 +90,5 @@ jobs:
steps:
- name: Deploy to Coolify
run: |
curl --request GET '${{ secrets.COOLIFY_WEBHOOK_WEB }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'
curl '${{ secrets.COOLIFY_WEBHOOK_WEB }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
api/package-lock.json
web/package-lock.json

View file

@ -1,4 +1,4 @@
# Welcome to ShiftSync 👋
# Welcome to ShiftSync
This is a new project, directed to create a website and app that is new, innovative, and works efficiently to schedule first responders/employees to shifts. The website and app is directed toward volunteer agencies specifically in both Fire Department and EMS fields. The website and app is currently in development and will hope to reach alpha by end of 2025.

View file

@ -4,7 +4,7 @@ WORKDIR /app
COPY ./package*.json ./
RUN npm ci
RUN npm cache clean --force && npm install --no-audit --no-fund
COPY . ./

22
api/middleware/auth.js Normal file
View file

@ -0,0 +1,22 @@
export const validateMedicationApiKey = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token || token !== process.env.MEDICATION_API_KEY) {
console.log('MEDICATION - User entered an Invalid token: ', token);
return res.status(401).json({ error: 'Unauthorized - Invalid API Key' });
}
next();
};
export const validateShiftSyncApiKey = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token || token !== process.env.SHIFTSYNC_API_KEY) {
console.log('SHIFT - User entered an Invalid token: ', token);
return res.status(401).json({ error: 'Unauthorized - Invalid API Key' });
}
next();
};

View file

@ -11,8 +11,9 @@
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"pg": "^8.16.2"
"express": "^5.2.0",
"pg": "^8.17.1",
"yup": "^1.7.1"
},
"devDependencies": {
"nodemon": "^3.1.10"

View file

@ -1,11 +1,11 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import userRouter from './services/user/index.js';
import departmentRouter from './services/department/index.js';
import pool from './services/postgres/postgresServices.js';
import { medicationRouter } from './services/medications/index.js';
import { shiftsRouter } from './services/shifts/index.js';
import { shiftRunQuery } from './services/shiftConnection.js';
import { validateMedicationApiKey, validateShiftSyncApiKey } from './middleware/auth.js';
dotenv.config();
import { routes } from './router/routes.js';
const app = express();
@ -19,18 +19,16 @@ const corsOptions = {
app.use(cors(corsOptions));
app.use(express.json({ limit: '10mb' }));
app.use('/api', routes);
app.get('*route', (req, res) => {
res.send("Hello from ShiftSync");
});
app.use(express.json());
const apiRouter = express.Router();
apiRouter.use('/user', userRouter);
apiRouter.use('/department', departmentRouter);
// ParamyxRx Router (/api/medication)
apiRouter.use('/medication', validateMedicationApiKey, medicationRouter);
// ShiftSync Router (/api/shifts)
apiRouter.use('/shifts', validateShiftSyncApiKey, shiftsRouter);
app.get("/api", (req, res) => {
res.json('Welcome to Shift Sync API');
@ -38,17 +36,23 @@ app.get("/api", (req, res) => {
app.use('/api', apiRouter);
app.get('/db-health', async (req, res) => {
app.get('/api/db-health', async (req, res) => {
try {
console.log('in');
const result = await pool.query('SELECT NOW()');
console.log('after result');
const result = await shiftRunQuery('SELECT NOW()');
res.json({ connected: true, time: result.rows[0].now });
} catch (err) {
console.error(err);
res.status(500).json({ connected: false, error: err.message });
}
});
app.get('/api/version', async (req, res) => {
try {
res.json('1.0.1');
} catch (err) {
console.error(err);
res.status(500).json({ connected: false, error: err.message });
}
});
app.listen(5172, () => {
console.log('Server Started on port 5172');

View file

@ -1,7 +1,7 @@
import {
userDataOperations
} from './operations/userData.js';
medicationOperations
} from './operations/medications.js';
export const databaseServices = {
...userDataOperations
...medicationOperations
}

View file

@ -0,0 +1,47 @@
import express from 'express';
import { databaseServices } from '../index.js';
import { fullMedicationInformationSchema } from '../validations/medications.js';
export const medicationRouter = express.Router();
medicationRouter.get('/', async (req, res) => {
let data;
try {
data = await databaseServices.getMedications();
res.status(200);
} catch (err) {
data = { Error: err?.message };
res.status(500);
} finally {
res.send(data);
}
});
medicationRouter.get('/base/', async (req, res) => {
let data;
try {
data = await databaseServices.getBaseMedications();
res.status(200);
} catch (err) {
data = { Error: err?.message };
res.status(500);
} finally {
res.send(data);
}
});
medicationRouter.post('/full/', async (req, res) => {
let data;
const body = req?.body;
try {
await fullMedicationInformationSchema.validate(body);
data = await databaseServices.getFullMedicationInformation(body);
res.status(200);
} catch (err) {
data = { Error: err?.message };
res.status(500);
} finally {
res.send(data);
}
});

View file

@ -0,0 +1,129 @@
import { paramyxRunQuery } from '../../paramyxConnection.js';
export const medicationHelpers = {
getBaseMedications: async () => {
const [medList, medRoutes] = await Promise.all([
paramyxRunQuery('SELECT * FROM medications'),
paramyxRunQuery('SELECT * FROM medication_routes')
]);
const medMap = {};
medList.forEach((med) => {
medMap[med.id] = {
id: med.id,
generic: med.generic,
trade: med.trade,
system: med.system,
adult: {
hasIV: false,
hasIO: false,
hasIM: false,
hasIN: false,
hasPO: false,
hasSL: false,
hasPR: false,
hasNEB: false,
hasET: false,
hasSGA: false
},
pediatric: {
hasIV: false,
hasIO: false,
hasIM: false,
hasIN: false,
hasPO: false,
hasSL: false,
hasPR: false,
hasNEB: false,
hasET: false,
hasSGA: false
}
};
});
medRoutes.forEach((route) => {
const medId = route.medication_id;
if (medMap[medId]) {
const target = route.is_adult ? medMap[medId].adult : medMap[medId].pediatric;
target.hasIV = !!route.intravenous;
target.hasIO = !!route.intraosseous;
target.hasIM = !!route.intramuscular;
target.hasIN = !!route.intranasal;
target.hasPO = !!route.oral;
target.hasSL = !!route.sublingual;
target.hasPR = !!route.rectal;
target.hasNEB = !!route.nebulizer;
target.hasET = !!route.endotracheal;
target.hasSGA = !!route.supraglottic;
}
});
const fullMedList = Object.values(medMap);
return fullMedList;
},
getFullMedicationInformation: async (drugId) => {
const [medInformation, medRoutes] = await Promise.all([
paramyxRunQuery(
`select
m.id,
m.generic,
m.trade,
m.system,
mi.class,
mi.indications,
mi.contraindications,
mi.precautions,
mi.adverse,
mi.onset,
mi.duration,
mi.tip,
mi.action
from medications m inner join medication_information mi on m.id = mi.id where m.id = $1;`,
[drugId]
),
paramyxRunQuery(`select * from medication_routes mr where medication_id = $1;`, [drugId])
]);
const fullMedicationInformation = {
...medInformation[0]
};
medRoutes?.forEach((row) => {
if (row?.is_adult) {
fullMedicationInformation.adult = {
totalMax: row.total_max,
iv: row.intravenous,
io: row.intraosseous,
im: row.intramuscular,
in: row.intranasal,
po: row.oral,
sl: row.sublingual,
pr: row.rectal,
neb: row.nebulizer,
et: row.endotracheal,
sga: row.supraglottic
}
} else {
fullMedicationInformation.pediatric = {
totalMax: row.total_max,
iv: row.intravenous,
io: row.intraosseous,
im: row.intramuscular,
in: row.intranasal,
po: row.oral,
sl: row.sublingual,
pr: row.rectal,
neb: row.nebulizer,
et: row.endotracheal,
sga: row.supraglottic
}
}
});
return fullMedicationInformation;
}
};

View file

@ -0,0 +1,39 @@
import { paramyxRunQuery } from '../paramyxConnection.js';
import { medicationHelpers } from './helpers/medications.js'
const getMedications = async () => {
try {
const dataResp = await paramyxRunQuery('SELECT * FROM medications');
return dataResp;
} catch (err) {
console.log('GET MEDICATIONS ERROR: ', err);
throw err;
}
};
const getBaseMedications = async () => {
try {
const dataResp = medicationHelpers.getBaseMedications();
return dataResp;
} catch (err) {
console.log('GET BASE MEDICATIONS ERROR: ', err);
throw err;
}
}
const getFullMedicationInformation = async (drug) => {
const { drugId } = drug;
try {
const dataResp = medicationHelpers.getFullMedicationInformation(drugId);
return dataResp;
} catch (err) {
console.log('GET FULL MEDICATION INFORMATION ERROR: ', err);
throw err;
}
}
export const medicationOperations = {
getMedications,
getBaseMedications,
getFullMedicationInformation
}

View file

@ -0,0 +1,23 @@
import { Pool } from 'pg';
import 'dotenv/config';
const poolCreds = {
host: process.env.POSTGRES_HOST,
database: process.env.POSTGRES_DB_PARAMYXRX,
port: process.env.POSTGRES_PORT,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD
};
const pool = new Pool(poolCreds);
export const paramyxRunQuery = async (query, params = []) => {
const client = await pool.connect();
try {
const pgQueryResponse = await client.query(query, params);
return pgQueryResponse?.rows || [];
} finally {
client.release();
}
}

View file

@ -0,0 +1,23 @@
import { Pool } from 'pg';
import 'dotenv/config';
const poolCreds = {
host: process.env.POSTGRES_HOST,
database: process.env.POSTGRES_DB_SHIFT,
port: process.env.POSTGRES_PORT,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD
};
const pool = new Pool(poolCreds);
export const shiftRunQuery = async (query, params = []) => {
const client = await pool.connect();
try {
const pgQueryResponse = await client.query(query, params);
return pgQueryResponse?.rows || [];
} finally {
client.release();
}
}

View file

@ -0,0 +1,3 @@
import express from 'express';
export const shiftsRouter = express.Router();

View file

@ -0,0 +1,5 @@
import * as Yup from 'yup';
export const fullMedicationInformationSchema = Yup.object().shape({
drugId: Yup.string().required("drugId is required"),
});

103
package-lock.json generated
View file

@ -90,29 +90,33 @@
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -347,9 +351,9 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -464,18 +468,19 @@
}
},
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
@ -684,31 +689,39 @@
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore-by-default": {
@ -796,9 +809,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
@ -1037,9 +1050,9 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@ -1061,18 +1074,18 @@
}
},
"node_modules/raw-body": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.6.3",
"unpipe": "1.0.0"
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
"node": ">= 0.10"
}
},
"node_modules/readdirp": {
@ -1305,9 +1318,9 @@
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"

View file

@ -4,7 +4,7 @@ WORKDIR /app
COPY ./package*.json ./
RUN npm ci
RUN npm cache clean --force && npm install --no-audit --no-fund
COPY . ./

View file

@ -16,7 +16,7 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"axios": "^1.10.0",
"axios": "^1.13.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
@ -32,6 +32,6 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"vite": "^6.3.5"
"vite": "^6.4.1"
}
}