Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s

- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh
  token moves to an HttpOnly cookie (omsorgCore), CORS added for the new
  browser origin, document download/preview switched to Blob-based browser
  APIs.
- Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml
  wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow
  that builds and pushes images to the repo's container registry on push to
  main and on version tags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 17:42:45 +02:00
co-authored by Claude Sonnet 5
parent e9e96a57dc
commit 598dfcd38a
461 changed files with 59753 additions and 2859 deletions
+40
View File
@@ -0,0 +1,40 @@
import { AbsencesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/absences von omsorgCore (AbsencesController) über den generierten Client
// (omsorgcore-client-ts). Anlegen (mit Date-Feldern) passiert nur über omsorgWeb/mitarbeiter-app,
// omsorgapp braucht hier Lesen + Bearbeiten (solange "Eingereicht") + Entscheiden (Genehmigen/Ablehnen).
// startDate/endDate müssen als Date-Objekte übergeben werden - siehe ordersApi.js für dasselbe Muster.
function toDatePayload(payload) {
return {
...payload,
startDate: payload.startDate ? new Date(payload.startDate) : payload.startDate,
endDate: payload.endDate ? new Date(payload.endDate) : payload.endDate
};
}
export async function listAbsences(accessToken, { status, type, employeeId, page = 1, pageSize = 20 } = {}) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesGetRaw({ status, type, employeeId, page, pageSize }));
}
export async function getAbsence(accessToken, id) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdGetRaw({ id }));
}
export async function updateAbsence(accessToken, id, payload) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdPutRaw({ id, updateAbsenceRequest: toDatePayload(payload) }));
}
export async function decideAbsence(accessToken, id, payload) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdDecisionPostRaw({ id, absenceDecisionRequest: payload }));
}
export async function deleteAbsence(accessToken, id) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdDeleteRaw({ id }));
}
+33
View File
@@ -0,0 +1,33 @@
import { Configuration } from "omsorgcore-client-ts";
import { API_BASE } from "./config.js";
// Baut die Configuration für eine generierte *Api-Klasse (omsorgcore-client-ts).
// credentials:"include" ist nötig, damit der Browser die HttpOnly-Refresh-Token-Cookie
// auf /api/auth/* mitschickt bzw. entgegennimmt (siehe omsorgCore/CLAUDE.md, "Auth-Flow").
export function configFor(accessToken) {
return new Configuration({ basePath: API_BASE, accessToken, credentials: "include" });
}
// Wandelt den Aufruf einer generierten `...Raw()`-Methode in denselben
// Vertrag um, den vorher httpClient.request(...) lieferte: { ok, status, data, error? }.
// Generierte Clients werfen bei Nicht-2xx eine ResponseError-Exception statt ein
// Ergebnisobjekt zurückzugeben - das fangen wir hier zentral ab.
export async function callApi(rawPromise) {
try {
const response = await rawPromise;
const data = await response.value();
return { ok: true, status: response.raw.status, data: data === undefined ? null : data };
} catch (err) {
if (err && err.name === "ResponseError") {
const status = err.response.status;
let data = null;
try {
data = await err.response.json();
} catch {
data = null;
}
return { ok: false, status, data };
}
return { ok: false, status: 0, data: null, error: err && err.message };
}
}
+23
View File
@@ -0,0 +1,23 @@
import { AuditLogApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt GET /api/audit-log (AuditLogController) über den generierten Client
// (omsorgcore-client-ts) - rein lesend, keine weiteren Verben.
export async function listAuditLog(
accessToken,
{ page = 1, pageSize = 50, entityType, entityId, actorUserId, category, fromUtc, toUtc } = {}
) {
const api = new AuditLogApi(configFor(accessToken));
return callApi(
api.apiAuditLogGetRaw({
page,
pageSize,
entityType: entityType || undefined,
entityId: entityId || undefined,
actorUserId: actorUserId || undefined,
category: category || undefined,
fromUtc: fromUtc ? new Date(fromUtc) : undefined,
toUtc: toUtc ? new Date(toUtc) : undefined
})
);
}
+75
View File
@@ -0,0 +1,75 @@
import { AuthApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/auth/* von omsorgCore über den generierten Client (omsorgcore-client-ts).
// refresh/logout brauchen keinen Refresh-Token-Parameter mehr - er steckt in der
// HttpOnly-Cookie, die der Browser dank credentials:"include" automatisch mitschickt.
function toTokenPair(data) {
return {
accessToken: data.accessToken,
expiresAt: data.expiresAt,
mustChangePassword: !!data.mustChangePassword
};
}
export async function login(username, password) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthLoginPostRaw({ loginRequest: { username, password } }));
if (!result.ok) {
return { ok: false, status: result.status, error: result.error || result.data?.error };
}
return { ok: true, ...toTokenPair(result.data) };
}
export async function refresh() {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthRefreshPostRaw());
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, ...toTokenPair(result.data) };
}
export async function logout() {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthLogoutPostRaw());
return { ok: result.ok, status: result.status };
}
export async function me(accessToken) {
const api = new AuthApi(configFor(accessToken));
const result = await callApi(api.apiAuthMeGetRaw());
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, username: result.data.username, role: result.data.role, permissions: result.data.permissions };
}
export async function requestPasswordReset(username) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthForgotPasswordRequestPostRaw({ forgotPasswordRequestRequest: { username } }));
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, status: result.data.status };
}
export async function verifyPasswordResetCode(username, pin) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthForgotPasswordVerifyPostRaw({ forgotPasswordVerifyRequest: { username, pin } }));
if (!result.ok) return { ok: false, status: result.status, error: result.data?.error };
return { ok: true, resetToken: result.data.resetToken };
}
export async function resetPassword(resetToken, newPassword) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthForgotPasswordResetPostRaw({ forgotPasswordResetRequest: { resetToken, newPassword } }));
return { ok: result.ok, status: result.status, data: result.data };
}
export async function changePassword(accessToken, currentPassword, newPassword) {
const api = new AuthApi(configFor(accessToken));
const result = await callApi(api.apiAuthChangePasswordPostRaw({ changePasswordRequest: { currentPassword, newPassword } }));
return { ok: result.ok, status: result.status, data: result.data };
}
export async function getPasswordPolicy() {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthPasswordPolicyGetRaw());
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, minLength: result.data.minLength };
}
+4
View File
@@ -0,0 +1,4 @@
// Einzige Stelle im Projekt, die die Adresse von omsorgCore kennt.
// Default passt zum lokalen `dotnet run --project src/OmsorgCore.Api` (http-Profil, Port 5245),
// überschreibbar per Vite-Env-Variable (VITE_OMSORG_CORE_URL) für andere Umgebungen.
export const API_BASE = import.meta.env.VITE_OMSORG_CORE_URL || "http://localhost:5245";
+25
View File
@@ -0,0 +1,25 @@
import { ContractsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/contracts von omsorgCore (ContractsController) über den generierten
// Client (omsorgcore-client-ts). Feldnamen sind camelCase, da ASP.NET Core
// standardmäßig camelCase-JSON ausgibt.
export async function listContracts(accessToken, { employeeId, facilityId, search, status, page = 1, pageSize = 20 } = {}) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsGetRaw({ employeeId, facilityId, search, status, page, pageSize }));
}
export async function createContract(accessToken, payload) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsPostRaw({ createContractRequest: payload }));
}
export async function updateContract(accessToken, id, payload) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsIdPutRaw({ id, updateContractRequest: payload }));
}
export async function deleteContract(accessToken, id) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsIdDeleteRaw({ id }));
}
+49
View File
@@ -0,0 +1,49 @@
import { DocumentsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
import { API_BASE } from "./config.js";
// Kapselt /api/documents von omsorgCore (DocumentsController). Der generierte Client wird für
// list/upload/delete genutzt; der Download-Endpoint NICHT über DocumentsApi (dessen
// apiDocumentsIdDownloadGetRaw ist als VoidApiResponse generiert und verwirft den Response-Body),
// sondern per direktem fetch, analog zu genericApi.js für nicht-generierte Endpunkte.
export async function listDocuments(accessToken, { entityType, entityId } = {}) {
const api = new DocumentsApi(configFor(accessToken));
return callApi(api.apiDocumentsGetRaw({ entityType, entityId }));
}
export async function uploadDocument(accessToken, { entityType, entityId, category, description, fileName, contentType, data }) {
const api = new DocumentsApi(configFor(accessToken));
// data ist ein ArrayBuffer (aus file.arrayBuffer() im UploadDocumentDialog) - Blob
// akzeptiert das im Browser direkt, kein Buffer.from(...) nötig (das war Node-spezifisch).
const file = new Blob([data], { type: contentType || "application/octet-stream" });
return callApi(api.apiDocumentsPostRaw({ entityType, entityId, category, description, file }));
}
export async function updateDocument(accessToken, id, payload) {
const api = new DocumentsApi(configFor(accessToken));
return callApi(api.apiDocumentsIdPutRaw({ id, updateDocumentRequest: payload }));
}
export async function deleteDocument(accessToken, id) {
const api = new DocumentsApi(configFor(accessToken));
return callApi(api.apiDocumentsIdDeleteRaw({ id }));
}
export async function downloadDocument(accessToken, id) {
const headers = {};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
let response;
try {
response = await fetch(`${API_BASE}/api/documents/${id}/download`, { headers });
} catch (err) {
return { ok: false, status: 0, data: null, error: err.message };
}
if (!response.ok) {
return { ok: false, status: response.status, data: null };
}
const blob = await response.blob();
return { ok: true, status: response.status, data: { blob } };
}
+30
View File
@@ -0,0 +1,30 @@
import { EmployeesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/employees von omsorgCore (EmployeesController) über den generierten
// Client (omsorgcore-client-ts). Feldnamen sind camelCase, da ASP.NET Core
// standardmäßig camelCase-JSON ausgibt.
export async function listEmployees(accessToken, { search, status, employmentType, page = 1, pageSize = 20 } = {}) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesGetRaw({ search, status, employmentType, page, pageSize }));
}
export async function createEmployee(accessToken, payload) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesPostRaw({ createEmployeeRequest: payload }));
}
export async function getEmployee(accessToken, id) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesIdGetRaw({ id }));
}
export async function updateEmployee(accessToken, id, payload) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesIdPutRaw({ id, updateEmployeeRequest: payload }));
}
export async function deleteEmployee(accessToken, id) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesIdDeleteRaw({ id }));
}
+30
View File
@@ -0,0 +1,30 @@
import { FacilitiesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/facilities von omsorgCore (FacilitiesController) über den generierten
// Client (omsorgcore-client-ts). Feldnamen sind camelCase, da ASP.NET Core
// standardmäßig camelCase-JSON ausgibt.
export async function listFacilities(accessToken, { search, crmStatus, followUpDueOnly, page = 1, pageSize = 20 } = {}) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesGetRaw({ search, crmStatus, followUpDueOnly, page, pageSize }));
}
export async function createFacility(accessToken, payload) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesPostRaw({ createFacilityRequest: payload }));
}
export async function getFacility(accessToken, id) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesIdGetRaw({ id }));
}
export async function updateFacility(accessToken, id, payload) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesIdPutRaw({ id, updateFacilityRequest: payload }));
}
export async function deleteFacility(accessToken, id) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesIdDeleteRaw({ id }));
}
+25
View File
@@ -0,0 +1,25 @@
import { FacilityContactsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/facilities/{facilityId}/contacts von omsorgCore (FacilityContactsController)
// über den generierten Client (omsorgcore-client-ts). Ansprechpartner sind eine
// 1:n-Unterressource von Facility, kein eigenständiger Endpunkt.
export async function listFacilityContacts(accessToken, facilityId) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsGetRaw({ facilityId }));
}
export async function createFacilityContact(accessToken, facilityId, payload) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsPostRaw({ facilityId, createFacilityContactRequest: payload }));
}
export async function updateFacilityContact(accessToken, facilityId, id, payload) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsIdPutRaw({ facilityId, id, updateFacilityContactRequest: payload }));
}
export async function deleteFacilityContact(accessToken, facilityId, id) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsIdDeleteRaw({ facilityId, id }));
}
@@ -0,0 +1,25 @@
import { FacilityQualificationRatesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/facilities/{facilityId}/qualification-rates von omsorgCore
// (FacilityQualificationRatesController) über den generierten Client (omsorgcore-client-ts).
// Qualifikationsabhängige Preise sind eine 1:n-Unterressource von Facility (FR-EIN-4).
export async function listFacilityQualificationRates(accessToken, facilityId) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesGetRaw({ facilityId }));
}
export async function createFacilityQualificationRate(accessToken, facilityId, payload) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesPostRaw({ facilityId, createFacilityQualificationRateRequest: payload }));
}
export async function updateFacilityQualificationRate(accessToken, facilityId, id, payload) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesIdPutRaw({ facilityId, id, updateFacilityQualificationRateRequest: payload }));
}
export async function deleteFacilityQualificationRate(accessToken, facilityId, id) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesIdDeleteRaw({ facilityId, id }));
}
+33
View File
@@ -0,0 +1,33 @@
import { API_BASE } from "./config.js";
// Ersatz für httpClient.cjs + den generischen api:get/api:post-IPC-Proxy - für Ressourcen
// ohne eigene <kategorie>Api.js-Datei (aktuell nur /api/admin/sessions*, /api/admin/email/*,
// siehe DebugSessionsPage.jsx).
export async function request(method, path, accessToken, body) {
const headers = { "Content-Type": "application/json" };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
let response;
try {
response = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
} catch (err) {
return { ok: false, status: 0, data: null, error: err.message };
}
if (response.status === 204) {
return { ok: true, status: 204, data: null };
}
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return { ok: response.ok, status: response.status, data };
}
+213
View File
@@ -0,0 +1,213 @@
import * as session from "./session.js";
import * as authApi from "./authApi.js";
import * as genericApi from "./genericApi.js";
import * as employeesApi from "./employeesApi.js";
import * as facilitiesApi from "./facilitiesApi.js";
import * as facilityContactsApi from "./facilityContactsApi.js";
import * as facilityQualificationRatesApi from "./facilityQualificationRatesApi.js";
import * as ordersApi from "./ordersApi.js";
import * as absencesApi from "./absencesApi.js";
import * as timeEntriesApi from "./timeEntriesApi.js";
import * as usersApi from "./usersApi.js";
import * as rolesApi from "./rolesApi.js";
import * as auditLogApi from "./auditLogApi.js";
import * as valueListsApi from "./valueListsApi.js";
import * as trashApi from "./trashApi.js";
import * as contractsApi from "./contractsApi.js";
import * as documentsApi from "./documentsApi.js";
// Ersetzt electron/preload.cjs' contextBridge.exposeInMainWorld('omsorg', {...}) - dieselbe
// Oberfläche als reines Browser-Objekt statt IPC-Bridge, damit keine der 76+ Aufrufstellen
// unter src/modules/** angefasst werden muss (siehe Plan "Electron -> Browser-SPA-Migration").
function withAuth(fn) {
return (...args) => session.withAuthRetry(() => fn(session.getAccessToken(), ...args));
}
// Ersetzt main.cjs' documents:download-Handler (dort: dialog.showSaveDialog + fs.writeFileSync).
async function downloadDocumentAsFile(id, fileName) {
const result = await session.withAuthRetry(() => documentsApi.downloadDocument(session.getAccessToken(), id));
if (!result.ok) return result;
const url = URL.createObjectURL(result.data.blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
return { ok: true };
}
export function buildOmsorgApi() {
return {
auth: {
login: async (username, password) => {
const result = await authApi.login(username, password);
if (!result.ok) {
const error = result.status === 0 ? "network_error" : result.status === 429 ? "too_many_attempts" : "invalid_credentials";
return { success: false, error };
}
session.applySession(result);
const snapshot = session.getSessionSnapshot();
return { success: true, user: snapshot.user, mustChangePassword: snapshot.mustChangePassword };
},
logout: async () => {
try {
await authApi.logout();
} catch {
/* best effort */
}
session.clearSession();
return { success: true };
},
getSession: async () => {
if (!session.getAccessToken()) {
await session.bootstrapSession();
}
return session.getSessionSnapshot();
},
onSessionChanged: session.onSessionChanged,
requestPasswordReset: async (username) => {
const result = await authApi.requestPasswordReset(username);
if (!result.ok) return { success: false, error: "network_error" };
return { success: true, status: result.status };
},
verifyPasswordResetCode: async (username, pin) => {
const result = await authApi.verifyPasswordResetCode(username, pin);
if (!result.ok) return { success: false, error: result.error || "invalid_or_expired" };
return { success: true, resetToken: result.resetToken };
},
resetPassword: async (resetToken, newPassword) => {
const result = await authApi.resetPassword(resetToken, newPassword);
if (!result.ok) {
const error = result.status === 400 ? "password_too_short" : "invalid_or_expired";
return { success: false, error, message: result.status === 400 && typeof result.data === "string" ? result.data : undefined };
}
return { success: true };
},
changePassword: async (currentPassword, newPassword) => {
if (!session.getAccessToken()) return { success: false, error: "not_authenticated" };
const result = await authApi.changePassword(session.getAccessToken(), currentPassword, newPassword);
if (!result.ok) {
const error = result.status === 401 ? "invalid_current_password" : result.status === 400 ? "password_too_short" : "unknown";
return { success: false, error, message: result.status === 400 && typeof result.data === "string" ? result.data : undefined };
}
session.clearSession();
return { success: true };
},
getPasswordPolicy: async () => {
const result = await authApi.getPasswordPolicy();
if (!result.ok) return { success: false };
return { success: true, minLength: result.minLength };
}
},
api: {
get: (path) => session.withAuthRetry(() => genericApi.request("GET", path, session.getAccessToken())),
post: (path, body) => session.withAuthRetry(() => genericApi.request("POST", path, session.getAccessToken(), body))
},
employees: {
list: withAuth(employeesApi.listEmployees),
create: withAuth(employeesApi.createEmployee),
update: withAuth(employeesApi.updateEmployee),
delete: withAuth(employeesApi.deleteEmployee)
},
facilities: {
list: withAuth(facilitiesApi.listFacilities),
create: withAuth(facilitiesApi.createFacility),
update: withAuth(facilitiesApi.updateFacility),
delete: withAuth(facilitiesApi.deleteFacility)
},
facilityContacts: {
list: withAuth(facilityContactsApi.listFacilityContacts),
create: withAuth(facilityContactsApi.createFacilityContact),
update: withAuth(facilityContactsApi.updateFacilityContact),
delete: withAuth(facilityContactsApi.deleteFacilityContact)
},
facilityQualificationRates: {
list: withAuth(facilityQualificationRatesApi.listFacilityQualificationRates),
create: withAuth(facilityQualificationRatesApi.createFacilityQualificationRate),
update: withAuth(facilityQualificationRatesApi.updateFacilityQualificationRate),
delete: withAuth(facilityQualificationRatesApi.deleteFacilityQualificationRate)
},
orders: {
list: withAuth(ordersApi.listOrders),
create: withAuth(ordersApi.createOrder),
get: withAuth(ordersApi.getOrder),
update: withAuth(ordersApi.updateOrder),
delete: withAuth(ordersApi.deleteOrder)
},
absences: {
list: withAuth(absencesApi.listAbsences),
get: withAuth(absencesApi.getAbsence),
update: withAuth(absencesApi.updateAbsence),
decide: withAuth(absencesApi.decideAbsence),
delete: withAuth(absencesApi.deleteAbsence)
},
timeEntries: {
list: withAuth(timeEntriesApi.listTimeEntries),
get: withAuth(timeEntriesApi.getTimeEntry),
update: withAuth(timeEntriesApi.updateTimeEntry),
submit: withAuth(timeEntriesApi.submitTimeEntry),
decide: withAuth(timeEntriesApi.decideTimeEntry),
delete: withAuth(timeEntriesApi.deleteTimeEntry)
},
users: {
list: withAuth(usersApi.listUsers),
create: withAuth(usersApi.createUser),
listPermissionOverrides: withAuth(usersApi.listPermissionOverrides),
addPermissionOverride: withAuth(usersApi.addPermissionOverride),
deletePermissionOverride: withAuth(usersApi.deletePermissionOverride),
update: withAuth(usersApi.updateUser),
resetPassword: withAuth(usersApi.resetPassword)
},
roles: {
list: withAuth(rolesApi.listRoles),
get: withAuth(rolesApi.getRole),
create: withAuth(rolesApi.createRole),
updatePermissions: withAuth(rolesApi.updateRolePermissions)
},
auditLog: {
list: withAuth(auditLogApi.listAuditLog)
},
valueLists: {
list: withAuth(valueListsApi.listValueLists),
listItems: withAuth(valueListsApi.listItems),
createItem: withAuth(valueListsApi.createItem),
updateItem: withAuth(valueListsApi.updateItem),
deleteItem: withAuth(valueListsApi.deleteItem),
getUsages: withAuth(valueListsApi.getUsages),
listTransitions: withAuth(valueListsApi.listTransitions),
replaceTransitions: withAuth(valueListsApi.replaceTransitions)
},
trash: {
listEmployees: withAuth(trashApi.listDeletedEmployees),
restoreEmployee: withAuth(trashApi.restoreEmployee),
listFacilities: withAuth(trashApi.listDeletedFacilities),
restoreFacility: withAuth(trashApi.restoreFacility),
listContracts: withAuth(trashApi.listDeletedContracts),
restoreContract: withAuth(trashApi.restoreContract),
listOrders: withAuth(trashApi.listDeletedOrders),
restoreOrder: withAuth(trashApi.restoreOrder),
listFacilityContacts: withAuth(trashApi.listDeletedFacilityContacts),
restoreFacilityContact: withAuth(trashApi.restoreFacilityContact),
listFacilityQualificationRates: withAuth(trashApi.listDeletedFacilityQualificationRates),
restoreFacilityQualificationRate: withAuth(trashApi.restoreFacilityQualificationRate),
listAbsences: withAuth(trashApi.listDeletedAbsences),
restoreAbsence: withAuth(trashApi.restoreAbsence),
listTimeEntries: withAuth(trashApi.listDeletedTimeEntries),
restoreTimeEntry: withAuth(trashApi.restoreTimeEntry)
},
contracts: {
list: withAuth(contractsApi.listContracts),
create: withAuth(contractsApi.createContract),
update: withAuth(contractsApi.updateContract),
delete: withAuth(contractsApi.deleteContract)
},
documents: {
list: withAuth(documentsApi.listDocuments),
upload: withAuth(documentsApi.uploadDocument),
update: withAuth(documentsApi.updateDocument),
delete: withAuth(documentsApi.deleteDocument),
download: (id, fileName) => downloadDocumentAsFile(id, fileName),
view: withAuth(documentsApi.downloadDocument)
}
};
}
+45
View File
@@ -0,0 +1,45 @@
import { OrdersApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/orders von omsorgCore (OrdersController) über den generierten
// Client (omsorgcore-client-ts). startDate/endDate müssen als Date-Objekte
// übergeben werden - die generierten ...ToJSON-Funktionen rufen .toISOString()
// darauf auf, ein reiner "YYYY-MM-DD"-String aus einem <input type="date">
// würde dabei crashen (siehe auditLogApi.js für dasselbe Muster bei Filtern).
function toDatePayload(payload) {
return {
...payload,
startDate: payload.startDate ? new Date(payload.startDate) : payload.startDate,
endDate: payload.endDate ? new Date(payload.endDate) : payload.endDate
};
}
export async function listOrders(
accessToken,
{ search, statusId, facilityId, priority, requiredQualification, shiftType, page = 1, pageSize = 20 } = {}
) {
const api = new OrdersApi(configFor(accessToken));
return callApi(
api.apiOrdersGetRaw({ search, statusId, facilityId, priority, requiredQualification, shiftType, page, pageSize })
);
}
export async function createOrder(accessToken, payload) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersPostRaw({ createOrderRequest: toDatePayload(payload) }));
}
export async function getOrder(accessToken, id) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersIdGetRaw({ id }));
}
export async function updateOrder(accessToken, id, payload) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersIdPutRaw({ id, updateOrderRequest: toDatePayload(payload) }));
}
export async function deleteOrder(accessToken, id) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersIdDeleteRaw({ id }));
}
+24
View File
@@ -0,0 +1,24 @@
import { RolesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/roles von omsorgCore (RolesController) über den generierten Client
// (omsorgcore-client-ts).
export async function listRoles(accessToken) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesGetRaw());
}
export async function getRole(accessToken, roleId) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesIdGetRaw({ id: roleId }));
}
export async function createRole(accessToken, payload) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesPostRaw({ createRoleRequest: payload }));
}
export async function updateRolePermissions(accessToken, roleId, payload) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesIdPermissionsPutRaw({ id: roleId, updateRolePermissionsRequest: payload }));
}
+128
View File
@@ -0,0 +1,128 @@
// Browser-Ersatz für das frühere Session-Management im Electron-Hauptprozess (main.cjs):
// Access-Token nur als Modul-Variable im Speicher (verschwindet bei Reload/Tab-Schließen),
// Refresh-Token liegt jetzt als HttpOnly-Cookie beim Server (nie per JS lesbar, siehe
// omsorgCore/CLAUDE.md "Auth-Flow") - refreshSilently() schickt dafür nur noch
// credentials:"include" (via authApi.refresh()), ohne den Token selbst zu kennen.
import { refresh as refreshRequest, me as meRequest } from "./authApi.js";
const REFRESH_BUFFER_MS = 2 * 60 * 1000;
let accessToken = null;
let expiresAt = null;
let user = null;
let mustChangePassword = false;
let refreshTimer = null;
const listeners = new Set();
export function getSessionSnapshot() {
return { isAuthenticated: !!accessToken, user, mustChangePassword };
}
function notify() {
const snapshot = getSessionSnapshot();
listeners.forEach((cb) => cb(snapshot));
}
export function getAccessToken() {
return accessToken;
}
export function onSessionChanged(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
}
// Rein für die Anzeige "angemeldet als ..." - die Signaturprüfung passiert serverseitig
// bei jedem authentifizierten Aufruf, hier wird nichts sicherheitsrelevantes entschieden.
function decodeJwtClaims(token) {
try {
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("")
);
return JSON.parse(json);
} catch {
return {};
}
}
function userFromAccessToken(token) {
const claims = decodeJwtClaims(token);
return {
username: claims["name"] || claims["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] || null,
role: claims["role"] || claims["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role"] || null
};
}
function scheduleRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
if (!expiresAt) return;
const delay = Math.max(new Date(expiresAt).getTime() - Date.now() - REFRESH_BUFFER_MS, 5000);
refreshTimer = setTimeout(refreshSilently, delay);
}
export function applySession(tokenPair) {
accessToken = tokenPair.accessToken;
expiresAt = tokenPair.expiresAt;
user = userFromAccessToken(tokenPair.accessToken);
mustChangePassword = !!tokenPair.mustChangePassword;
scheduleRefresh();
notify();
refreshPermissions();
}
// Rechte kommen nicht aus dem JWT (das trägt nur den Rollennamen), sondern aus /api/auth/me -
// läuft nach dem schnellen JWT-basierten Zwischenstand nach und broadcastet erneut, sobald da.
async function refreshPermissions() {
const tokenAtCallTime = accessToken;
if (!tokenAtCallTime) return;
const profile = await meRequest(tokenAtCallTime);
if (!profile.ok || accessToken !== tokenAtCallTime) return;
user = { username: profile.username, role: profile.role, permissions: profile.permissions };
notify();
}
export function clearSession() {
accessToken = null;
expiresAt = null;
user = null;
mustChangePassword = false;
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = null;
notify();
}
// Proaktiver Timer kurz vor Ablauf UND Fallback bei 401 (z.B. nach langer Inaktivität) -
// schickt keinen Token mehr mit, die HttpOnly-Cookie übernimmt das.
export async function refreshSilently() {
const result = await refreshRequest();
if (!result.ok) {
clearSession();
return false;
}
applySession(result);
return true;
}
// Ersetzt main.cjs' bootstrapSession(): läuft einmal beim App-Start, damit ein Reload
// (der die Modul-Variable oben leert) die Session über die noch gültige HttpOnly-Cookie
// transparent wiederherstellt, statt den Nutzer erneut einloggen zu lassen.
export async function bootstrapSession() {
if (accessToken) return true;
return refreshSilently();
}
// Ruft callFn() auf und wiederholt einmal nach Silent-Refresh bei 401 - gemeinsame
// Grundlage für alle <resource>Api.js-Aufrufe und den generischen api:*-Proxy.
export async function withAuthRetry(callFn) {
if (!accessToken) return { ok: false, status: 401, data: null };
let result = await callFn();
if (result.status === 401 && (await refreshSilently())) {
result = await callFn();
}
return result;
}
+44
View File
@@ -0,0 +1,44 @@
import { TimeEntriesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/time-entries von omsorgCore (TimeEntriesController) über den generierten Client
// (omsorgcore-client-ts). Anlegen (mit Date-Feldern) passiert nur über omsorgWeb/mitarbeiter-app,
// omsorgapp braucht hier Lesen + Bearbeiten (solange status.isEditableByOwner) + Prüfungs-Entscheidungen.
// date muss als Date-Objekt übergeben werden - siehe absencesApi.js für dasselbe Muster.
function toDatePayload(payload) {
return {
...payload,
date: payload.date ? new Date(payload.date) : payload.date
};
}
export async function listTimeEntries(accessToken, { statusId, employeeId, orderId, page = 1, pageSize = 20 } = {}) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesGetRaw({ statusId, employeeId, orderId, page, pageSize }));
}
export async function getTimeEntry(accessToken, id) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdGetRaw({ id }));
}
export async function updateTimeEntry(accessToken, id, payload) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdPutRaw({ id, updateTimeEntryRequest: toDatePayload(payload) }));
}
export async function submitTimeEntry(accessToken, id) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdSubmitPostRaw({ id }));
}
export async function decideTimeEntry(accessToken, id, payload) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdDecisionPostRaw({ id, timeEntryDecisionRequest: payload }));
}
export async function deleteTimeEntry(accessToken, id) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdDeleteRaw({ id }));
}
+85
View File
@@ -0,0 +1,85 @@
import { TrashApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/trash von omsorgCore (TrashController) über den generierten Client
// (omsorgcore-client-ts) - Papierkorb für die Objekte mit Soft-Delete.
export async function listDeletedEmployees(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashEmployeesGetRaw({ search }));
}
export async function restoreEmployee(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashEmployeesIdRestorePostRaw({ id }));
}
export async function listDeletedFacilities(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilitiesGetRaw({ search }));
}
export async function restoreFacility(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilitiesIdRestorePostRaw({ id }));
}
export async function listDeletedContracts(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashContractsGetRaw({ search }));
}
export async function restoreContract(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashContractsIdRestorePostRaw({ id }));
}
export async function listDeletedOrders(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashOrdersGetRaw({ search }));
}
export async function restoreOrder(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashOrdersIdRestorePostRaw({ id }));
}
export async function listDeletedFacilityContacts(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityContactsGetRaw({ search }));
}
export async function restoreFacilityContact(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityContactsIdRestorePostRaw({ id }));
}
export async function listDeletedFacilityQualificationRates(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityQualificationRatesGetRaw({ search }));
}
export async function restoreFacilityQualificationRate(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityQualificationRatesIdRestorePostRaw({ id }));
}
export async function listDeletedAbsences(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashAbsencesGetRaw({ search }));
}
export async function restoreAbsence(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashAbsencesIdRestorePostRaw({ id }));
}
export async function listDeletedTimeEntries(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashTimeEntriesGetRaw({ search }));
}
export async function restoreTimeEntry(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashTimeEntriesIdRestorePostRaw({ id }));
}
+39
View File
@@ -0,0 +1,39 @@
import { UsersApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/users von omsorgCore (UsersController) über den generierten Client
// (omsorgcore-client-ts).
export async function listUsers(accessToken) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersGetRaw());
}
export async function createUser(accessToken, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersPostRaw({ createUserRequest: payload }));
}
export async function listPermissionOverrides(accessToken, userId) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPermissionOverridesGetRaw({ id: userId }));
}
export async function addPermissionOverride(accessToken, userId, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPermissionOverridesPostRaw({ id: userId, addUserPermissionOverrideRequest: payload }));
}
export async function deletePermissionOverride(accessToken, userId, overrideId) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPermissionOverridesOverrideIdDeleteRaw({ id: userId, overrideId }));
}
export async function updateUser(accessToken, userId, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPutRaw({ id: userId, updateUserRequest: payload }));
}
export async function resetPassword(accessToken, userId, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdResetPasswordPostRaw({ id: userId, resetUserPasswordRequest: payload }));
}
+45
View File
@@ -0,0 +1,45 @@
import { ValueListsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/value-lists von omsorgCore (ValueListsController) über den generierten
// Client (omsorgcore-client-ts) - konfigurierbare Auswahllisten (Mitarbeiterstatus,
// Beschäftigungsart, CRM-Status, Einrichtungstyp, Vertragstyp/-status, Auftragsstatus).
export async function listValueLists(accessToken) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsGetRaw());
}
export async function listItems(accessToken, key) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsGetRaw({ key }));
}
export async function createItem(accessToken, key, payload) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsPostRaw({ key, createValueListItemRequest: payload }));
}
export async function updateItem(accessToken, key, id, payload) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsIdPutRaw({ key, id, updateValueListItemRequest: payload }));
}
export async function deleteItem(accessToken, key, id) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsIdDeleteRaw({ key, id }));
}
export async function getUsages(accessToken, key, id) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsIdUsagesGetRaw({ key, id }));
}
export async function listTransitions(accessToken, key) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyTransitionsGetRaw({ key }));
}
export async function replaceTransitions(accessToken, key, payload) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyTransitionsPutRaw({ key, valueListTransitionRequest: payload }));
}