update - with versions

This commit is contained in:
2025-06-30 14:41:37 +00:00
parent 841cb61acf
commit 732cac4393
30 changed files with 5867 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
# Resource Provider (Node.js) Dockerfile
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Install pnpm globally
RUN npm install -g pnpm
# Copy package.json and pnpm-lock.yaml
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Expose port
EXPOSE 3000
# Start the Node.js application
CMD ["node", "server.js"]

View File

@@ -0,0 +1,55 @@
export const STATIC_DIR =
process.env.NODE_ENV === "production" ? "/static-files" : "../static-files";
const _TOPICS = [
"Социалната медицина като наука определение, предмет, задачи, методи. Обществено здраве дефиниране, цикъл на социално-здравните явления. Системен подход при анализ на общественото здраве.",
"Здраве дефиниция, концепции, същност и измерения. Детерминанти на здравето. Нова философия за здраве. Континуум на здравето. Традиционно и съвременно разбиране за здраве.",
"Социално-медицински подход към личността в здраве и болест. Социални фактори на здравето / болестта. Социална профилактика, социална терапия, социална рехабилитация. Социална история на заболяването / болния.",
"Демографски измерители на общественото здраве. Статика на населението –значение за здравеопазването. Демографски показатели за естествено движение на населението.",
"Риск и рискови фактори. Дефиниция, видове, класификация. Рискови групи. Подходи за определяне на рискови групи в здравеопазването. Фази на естественото развитие на патологичния процес.",
"Профилактика на заболяванията. Нива на профилактика - цели, прицелни контингенти, обхват. Стратегии за първична профилактика.",
"Здравна система определение, цели, функции, типове. Континуум на здравните дейности. Здравни заведения определение и класификация.",
"Здравна помощ дефиниция, характеристики, нива на специализация. Здравни потребности и здравни нужди определение. Функции на здравните служби, организационни принципи.",
"Първична здравна помощ. Извънболнична специализирана медицинска помощ. Лечебни заведения за извънболнична медицинска помощ определение и класификация.",
"Болнична медицинска помощ. Видове болнични заведения. Съвременни тенденции за развитие на болничната помощ.",
"Съвременни проблеми и промени в световното здраве. Здравна политика. Приоритети в здравеопазването.",
"Здравна култура дефиниране, видове. Насоки на проява на субективна здравна култура. Планиране на здравно-образователни програми. Формиране на мотиви за здравно поведение.",
"Здравно възпитание - цели, принципи, фази, форми - класически и модерни.",
"Хронични неинфекциозни заболявания - характеристика, обществена значимост, рискови фактори, действия за ограничаването им.",
"Промоция на здравето - определение, цели и компоненти. Съвременни приоритети пред общественото здравеопазване. Актуални области и подходи, залегнали в Отавската харта.",
"Здравословен начин на живот. Аспекти и компоненти на начина на живот. Нагласи и мотивация за водене на здравословен начин на живот.",
"Правен режим на социалното осигуряване. КСО, ЗСП, ЗИХУ.",
"Закон за здравето. Органи в системата на здравеопазването.",
"Закон за здравното осигуряване. Принципи на здравното осигуряване.",
"Закон за лечебните заведения. Видове лечебни заведения.",
"Закон за съсловната организация на медицинските сестри, акушерките и асоциираните медицински специалисти. Функции, устройство, права и задължения.",
"Кодекс на професионалната етика. Здравна информация и документация. Лични данни на пациентите",
"Права на пациента. Правни способи за защита.",
"Кодекс на труда. Характеристика на трудовото правоотношение.",
"Юридическа отговорност. Гражданска отговорност на медицинските специалисти. Дисциплинарна отговорност.",
"Административно-наказателна отговорност на медицинските специалисти. Лишаване от права.",
"Наказателна отговорност на медицинските специалисти.",
];
const N_RESOURCES = 9;
export const TOPICS = _TOPICS.map((topicTitle, topicIdx) => {
const topicSeq = topicIdx + 1;
const topicId = `T${topicSeq.toString().padStart(2, "0")}`;
const resources = Array.from({ length: N_RESOURCES }, (_, i) => {
const resourceSeq = i + 1;
const resourceId = `${topicId}_R${resourceSeq.toString().padStart(2, "0")}`;
return {
id: resourceId,
seq: resourceSeq,
filename: null,
};
});
return {
id: topicId,
seq: topicSeq,
title: topicTitle,
resources,
};
});
export const FILENAME_REGEX = /^F_T\d+_R\d+_\d+.md$/;

View File

@@ -0,0 +1,68 @@
import { promises as fs } from "fs";
import path from "path";
import { FILENAME_REGEX, STATIC_DIR, TOPICS } from "./constants.js";
export const cache = new Map();
function getObjectWithMaxTimestamp(array) {
if (!array || array.length === 0) {
return null;
}
return array.reduce((max, current) => {
return current.timestamp > max.timestamp ? current : max;
});
}
async function populateResources() {
const filenamesRaw = await fs.readdir(STATIC_DIR);
const filenames = filenamesRaw
.filter((filename) => FILENAME_REGEX.test(filename))
.map((filename) => {
const parts = filename.replace(".md", "").split("_");
if (parts.length !== 4 || parts[0] !== "F") {
console.warn(`File not following format: ${filename}`);
return;
}
const topicSeq = Number(parts[1].substring(1));
const resourceSeq = Number(parts[2].substring(1));
const timestamp = Number(parts[3]);
return { topicSeq, resourceSeq, timestamp, filename };
});
const topicsBySeq = Object.groupBy(filenames, ({ topicSeq }) => topicSeq);
TOPICS.forEach((topic) => {
if (topic.seq in topicsBySeq) {
const resourcesBySeq = Object.groupBy(
topicsBySeq[topic.seq],
({ resourceSeq }) => resourceSeq,
);
topic.resources.forEach((resource) => {
if (resource.seq in resourcesBySeq) {
const latestResource = getObjectWithMaxTimestamp(
resourcesBySeq[resource.seq],
);
resource.filename = latestResource.filename;
}
});
}
});
}
export async function getStructure({ refresh = false } = {}) {
const structureKey = "structure";
if (refresh) {
cache.delete(structureKey);
}
if (!cache.has(structureKey)) {
await populateResources();
cache.set(structureKey, TOPICS);
}
return cache.get(structureKey);
}

View File

@@ -0,0 +1,24 @@
{
"name": "resource-provider",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.5.2",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"morgan": "^1.10.0",
"mutter": "^1.0.1",
"nodemon": "^3.1.10",
"prettier": "^3.6.1",
"socket.io": "^4.8.1"
}
}

1055
resource-provider/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

208
resource-provider/server.js Normal file
View File

@@ -0,0 +1,208 @@
import { readdir } from "fs/promises";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import path from "path";
import fs from "fs/promises";
import { createServer } from "http";
import { Server } from "socket.io";
import morgan from "morgan";
import { STATIC_DIR, TOPICS } from "./constants.js";
import { getStructure, cache } from "./helper.js";
if (typeof process.env.API_TOKEN === "undefined") {
throw new Error("Service cannot be started without API_TOKEN");
}
// Load cache on start
getStructure({ refresh: true }).catch(console.error);
const app = express();
const server = createServer(app);
// Global boolean array
let booleanArray = new Array(10).fill(false);
// Socket.IO setup with CORS
const io = new Server(server, {
cors: {
origin: "http://localhost:5173", // React app URL
methods: ["GET", "POST"],
},
});
const corsOptions = {
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
origin: (origin, callback) => {
if (!origin) {
if (process.env.NODE_ENV === "production") {
return callback(new Error("Origin required in production"));
}
return callback(null, true);
}
if (isOriginAllowed(origin)) {
return callback(null, true);
} else {
return callback(new Error("Not allowed by CORS"));
}
},
};
app.use(cors(corsOptions));
app.use(helmet());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan("tiny"));
// Socket connection handling
//io.on("connection", (socket) => {
// console.log("Client connected:", socket.id);
//
// // Send current array state to newly connected client
// socket.emit("arrayChanged", booleanArray);
//
// // Handle array updates from client
// socket.on("setArrayValue", (data) => {
// const { index, value } = data;
//
// if (index >= 0 && index < booleanArray.length) {
// booleanArray[index] = value;
// console.log(`Updated index ${index} to ${value}`);
//
// // Broadcast updated array to all clients
// io.emit("arrayChanged", booleanArray);
// }
// });
//
// // Handle getting current array state
// socket.on("getArray", () => {
// socket.emit("arrayChanged", booleanArray);
// });
//
// socket.on("disconnect", () => {
// console.log("Client disconnected:", socket.id);
// });
//});
app.get("/_health", (req, res) => {
res.json({ healthy: true });
});
app.get(
"/resources-status",
asyncHandler((req, res) => {
res.json({ array: booleanArray });
}),
);
// Serve static files with automatic ETag handling
app.use(
"/content",
express.static(STATIC_DIR, {
etag: true, // Enable automatic ETag generation
lastModified: true, // Include Last-Modified header
maxAge: 3600000, // Cache for 1 hour, but always revalidate
immutable: false, // Files can change
}),
);
app.get(
"/structure",
asyncHandler(async (req, res) => {
let { refresh } = req.query;
refresh = Boolean(refresh);
const structure = await getStructure({ refresh });
res.json(structure);
}),
);
app.post(
"/resources",
verifyToken,
asyncHandler(async (req, res) => {
try {
const { topicSeq, resourceSeq, content } = req.body;
if (!topicSeq || !resourceSeq || !content) {
res.status(400).json({ message: "Missing body" });
return;
}
if (
typeof topicSeq !== "number" ||
typeof resourceSeq !== "number" ||
typeof content !== "string"
) {
res.status(400).json({ message: "Invalid body" });
}
const topicIdx = topicSeq - 1;
const resourceIdx = resourceSeq - 1;
const topics = await getStructure();
const resource = topics[topicIdx].resources[resourceIdx];
if (!resource) {
res.status(404).json({ message: "Resource not found to update" });
}
const filename = `F_${resource.id}_${Date.now()}.md`;
const filePath = path.join(STATIC_DIR, filename);
await fs.writeFile(filePath, content, "utf8");
resource.filename = filename;
res.json({ success: true, filename, structure: topics });
} catch (error) {
console.log(error);
res.status(500).json({ error: "Failed to update topic resource" });
}
}),
);
app.use(errorRequestHandler);
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log("Resource Provider started"));
/**
* HELPERS
*/
function asyncHandler(fn) {
return (req, res, next) => {
return Promise.resolve(fn(req, res, next)).catch(next);
};
}
function errorRequestHandler(error, _req, res, next) {
if (error) {
console.log(error);
res
.status(error.status || 500)
.json({ message: error.message || "Server failed" });
} else {
next();
}
}
function isOriginAllowed(origin) {
const url = new URL(origin);
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
return true;
}
return url.hostname.endsWith("tomastm.com");
}
function verifyToken(req, res, next) {
const token = req.headers["token"];
if (!token || token !== process.env.API_TOKEN) {
res.status(401).json({ message: "Token not provided" });
return;
}
next();
}