Compare commits

..

8 commits

30 changed files with 1254 additions and 400 deletions

View file

@ -15,6 +15,14 @@ npm run dev
npm run dev -- --open npm run dev -- --open
``` ```
### Handle the prisma ORM
```bash
./run/db_up.sh # Start the docker container, create a new database and run migrations + insert test data
npx prisma migrate dev --name my_migration --create-only # Create a new migration
npx prisma migrate dev # Apply migrations to the database
```
### Building ### Building
To create a production version of your app: To create a production version of your app:

View file

@ -18,49 +18,53 @@
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.27.0", "@auth/core": "^0.27.0",
"@auth/sveltekit": "^0.13.0",
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@prisma/client": "^5.9.1", "@prisma/client": "^5.10.2",
"isomorphic-dompurify": "^2.3.0", "diff": "^5.2.0",
"isomorphic-dompurify": "^2.4.0",
"marked": "^12.0.0", "marked": "^12.0.0",
"set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8", "svelte-floating-ui": "^1.5.8",
"zod": "^3.22.4", "zod": "^3.22.4",
"zod-form-data": "^2.0.2" "zod-form-data": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.41.2", "@playwright/test": "^1.42.0",
"@sveltejs/adapter-node": "^4.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.5.0", "@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2", "@sveltejs/vite-plugin-svelte": "^3.0.2",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@trpc/client": "^10.45.1", "@trpc/client": "^10.45.1",
"@trpc/server": "^10.45.1", "@trpc/server": "^10.45.1",
"@types/node": "^20.11.19", "@types/diff": "^5.0.9",
"@typescript-eslint/eslint-plugin": "^7.0.1", "@types/node": "^20.11.21",
"@typescript-eslint/parser": "^7.0.1", "@types/set-cookie-parser": "^2.4.7",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"daisyui": "^4.7.2", "daisyui": "^4.7.2",
"eslint": "^8.56.0", "dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3", "eslint-plugin-no-relative-import-paths": "^1.5.3",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
"postcss-import": "^16.0.1", "postcss-import": "^16.0.1",
"postcss-nesting": "^12.0.2", "postcss-nesting": "^12.0.4",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.1", "prettier-plugin-svelte": "^3.2.2",
"prisma": "^5.9.1", "prisma": "^5.10.2",
"svelte": "^4.2.11", "svelte": "^4.2.12",
"svelte-check": "^3.6.4", "svelte-check": "^3.6.5",
"sveltekit-superforms": "^2.3.0", "sveltekit-superforms": "^2.6.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"trpc-sveltekit": "^3.5.26", "trpc-sveltekit": "^3.5.27",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"tsx": "^4.7.1", "tsx": "^4.7.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.1.3", "vite": "^5.1.4",
"vitest": "^1.3.0" "vitest": "^1.3.1"
}, },
"type": "module" "type": "module"
} }

File diff suppressed because it is too large Load diff

View file

@ -33,5 +33,5 @@ EXECUTE PROCEDURE update_entry_tsvec ();
ALTER TABLE patients ALTER TABLE patients
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED; ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
CREATE INDEX entries_tsvec ON entries USING GIN (tsvec); CREATE INDEX entries_tsvec_idx ON entries USING GIN (tsvec);
CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops); CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops);

View file

@ -0,0 +1,5 @@
CREATE COLLATION NATURAL_CI (provider = icu, locale = 'en-u-kn-true');
ALTER TABLE stations ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
ALTER TABLE rooms ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
ALTER TABLE categories ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;

View file

@ -108,6 +108,7 @@ model Entry {
tsvec Unsupported("tsvector")? tsvec Unsupported("tsvector")?
@@map("entries") @@map("entries")
@@index([tsvec], type: Gin)
} }
model EntryVersion { model EntryVersion {

View file

@ -2,13 +2,14 @@
set -e set -e
DIR="$(cd "$(dirname "$0")" && pwd)" DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
docker-compose up -d docker compose up -d
echo 'Waiting for database to be ready...' echo 'Waiting for database to be ready...'
"$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!' "$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!'
# Create temporary test database # Create temporary test database
docker-compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test' docker compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test'
cd "$DIR/../" cd "$DIR/../"
DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force

6
src/app.d.ts vendored
View file

@ -1,9 +1,13 @@
import type { Session } from "@auth/core/types";
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} interface Locals {
session: Session;
}
// interface PageData {} // interface PageData {}
// interface Platform {} // interface Platform {}
} }

View file

@ -17,6 +17,10 @@ button {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.border-1 {
border-width: 1px;
}
.v-form-field > input { .v-form-field > input {
@apply input input-bordered w-full max-w-xs; @apply input input-bordered w-full max-w-xs;
} }
@ -25,3 +29,32 @@ button {
.v-form-field > [aria-invalid="true"] { .v-form-field > [aria-invalid="true"] {
@apply input-error; @apply input-error;
} }
.card2 {
@apply bg-base-200;
@apply rounded-xl;
@apply mb-8;
@apply flex flex-col;
@apply border-solid border-base-content/30 border-[1px];
.row {
@apply flex flex-row p-2;
@apply border-solid border-base-content/30 border-t-[1px];
}
.row:first-child {
@apply rounded-t-xl border-none;
}
.row:last-child {
@apply rounded-b-xl;
}
.c-light {
@apply bg-base-content/20;
}
.c-primary {
@apply bg-primary text-primary-content;
}
}

View file

@ -13,9 +13,10 @@ import { skAuthHandle } from "$lib/server/auth";
* auth mechanism) * auth mechanism)
*/ */
const authorization: Handle = async ({ event, resolve }) => { const authorization: Handle = async ({ event, resolve }) => {
// Allowed pages without login: TRPC API (has its own auth hook), Auth.js internal
// pages and the login site
if (!/^\/(login|trpc)/.test(event.url.pathname)) { if (!/^\/(login|trpc)/.test(event.url.pathname)) {
const session = await event.locals.getSession(); if (!event.locals.session) {
if (!session) {
const params = new URLSearchParams({ returnURL: event.url.pathname }); const params = new URLSearchParams({ returnURL: event.url.pathname });
redirect(303, "/login?" + params.toString()); redirect(303, "/login?" + params.toString());
} }

View file

@ -2,10 +2,15 @@
import { URL_ENTRIES } from "$lib/shared/constants"; import { URL_ENTRIES } from "$lib/shared/constants";
import type { Category } from "$lib/shared/model"; import type { Category } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util"; import { gotoEntityQuery } from "$lib/shared/util";
import { getTextColor, colorToHex, hexToColor } from "$lib/shared/util/colors";
export let category: Category; export let category: Category;
export let baseUrl = URL_ENTRIES; export let baseUrl = URL_ENTRIES;
$: textColor = category.color
? colorToHex(getTextColor(hexToColor(category.color)))
: null;
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
gotoEntityQuery( gotoEntityQuery(
{ {
@ -22,6 +27,8 @@
<button <button
class="badge ellipsis" class="badge ellipsis"
class:badge-neutral={!category.color} class:badge-neutral={!category.color}
style={category.color ? `background-color: ${category.color};` : undefined} style={category.color
? `color: ${textColor}; background-color: #${category.color};`
: undefined}
on:click={onClick}>{category.name}</button on:click={onClick}>{category.name}</button
> >

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { mdiChevronLeft } from "@mdi/js";
import Icon from "./Icon.svelte";
export let title = "";
export let backHref: string | undefined = undefined;
</script>
<div class="mb-4 flex flex-row">
<div class="flex flex-wrap items-center gap-2">
{#if backHref}
<a href={backHref} class="btn btn-sm btn-circle btn-ghost">
<Icon path={mdiChevronLeft} size={1.8} />
</a>
{/if}
<h1 class="heading">{title}</h1>
<slot />
</div>
<slot name="rightBtn" />
</div>

View file

@ -1,12 +1,21 @@
import { SvelteKitAuth, type SvelteKitAuthConfig } from "@auth/sveltekit"; import {
import { Auth, raw, skipCSRFCheck } from "@auth/core"; Auth,
isAuthAction,
raw,
setEnvDefaults,
skipCSRFCheck,
type AuthConfig,
} from "@auth/core";
import Keycloak from "@auth/core/providers/keycloak"; import Keycloak from "@auth/core/providers/keycloak";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { PrismaAdapter } from "$lib/server/authAdapter"; import { PrismaAdapter } from "$lib/server/authAdapter";
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
import { redirect, type RequestEvent } from "@sveltejs/kit"; import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit";
import { parse } from "set-cookie-parser";
export const AUTH_CFG: SvelteKitAuthConfig = { const AUTH_BASE_PATH: string = "/auth";
export const AUTH_CFG: AuthConfig = {
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
providers: [ providers: [
Keycloak({ Keycloak({
@ -36,10 +45,26 @@ export const AUTH_CFG: SvelteKitAuthConfig = {
return opt.session; return opt.session;
}, },
}, },
basePath: AUTH_BASE_PATH,
trustHost: true, trustHost: true,
}; };
/*
Info: the @auth/sveltekit library has currently an issue with building request URLs.
If the application is run in production mode, it always creates HTTPS URLs
for the internal auth.js requests which fails if the application is not running
with HTTPS. This is the reason for not using the library and implementing the
auth handler function myself.
Original source: https://github.com/nextauthjs/next-auth/blob/main/packages/frameworks-sveltekit/src/lib/actions.ts
ISC License
*/
function authjsUrl(event: RequestEvent, authjsEndpoint: string): string {
const url = event.url;
return url.protocol + "//" + url.host + AUTH_BASE_PATH + "/" + authjsEndpoint;
}
export async function makeAuthjsRequest( export async function makeAuthjsRequest(
event: RequestEvent, event: RequestEvent,
authjsEndpoint: string, authjsEndpoint: string,
@ -48,11 +73,8 @@ export async function makeAuthjsRequest(
const headers = new Headers(event.request.headers); const headers = new Headers(event.request.headers);
headers.set("Content-Type", "application/x-www-form-urlencoded"); headers.set("Content-Type", "application/x-www-form-urlencoded");
const url = event.url;
const baseUrl = url.protocol + "//" + url.host;
const body = new URLSearchParams(params); const body = new URLSearchParams(params);
const req = new Request(baseUrl + authjsEndpoint, { const req = new Request(authjsUrl(event, authjsEndpoint), {
method: "POST", method: "POST",
headers, headers,
body, body,
@ -64,4 +86,38 @@ export async function makeAuthjsRequest(
return redirect(302, res.redirect ?? ""); return redirect(302, res.redirect ?? "");
} }
export const skAuthHandle = SvelteKitAuth(AUTH_CFG).handle; export async function auth(event: RequestEvent) {
const { request: req } = event;
setEnvDefaults(env, AUTH_CFG);
const sessionUrl = authjsUrl(event, "session");
const request = new Request(sessionUrl, {
headers: { cookie: req.headers.get("cookie") ?? "" },
});
const response = await Auth(request, AUTH_CFG);
const authCookies = parse(response.headers.getSetCookie());
for (const cookie of authCookies) {
const { name, value, ...options } = cookie;
// @ts-expect-error - Review: SvelteKit and set-cookie-parser are mismatching
event.cookies.set(name, value, { path: "/", ...options });
}
const { status = 200 } = response;
const data = await response.json();
if (!data || !Object.keys(data).length) return null;
if (status === 200) return data;
throw new Error(data.message);
}
export const skAuthHandle: Handle = async ({ event, resolve }) => {
const { url, request } = event;
if (
url.pathname.startsWith(AUTH_BASE_PATH + "/") &&
isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0])
) {
return Auth(request, AUTH_CFG);
} else {
event.locals.session = await auth(event);
}
return resolve(event);
};

View file

@ -6,13 +6,11 @@ import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
// hence the eslint-disable rule // hence the eslint-disable rule
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function createContext(event: RequestEvent) { export async function createContext(event: RequestEvent) {
const session = await event.locals.getSession(); if (!event.locals.session?.user) {
if (!session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" }); throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
} }
const user = ZUser.parse(session?.user); const user = ZUser.parse(event.locals.session?.user);
return { return {
user, user,
}; };

View file

@ -17,6 +17,7 @@ import {
newEntryExecution, newEntryExecution,
newEntryVersion, newEntryVersion,
} from "$lib/server/query"; } from "$lib/server/query";
import { versionsDiff } from "$lib/shared/util/diff";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
@ -38,6 +39,12 @@ export const entryRouter = t.router({
versions: t.procedure versions: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))), .query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))),
versionsDiff: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const versions = await getEntryVersions(opts.input);
return versionsDiff(versions);
})
),
executions: t.procedure executions: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))), .query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),

View file

@ -1,12 +0,0 @@
import type { RequestEvent } from "@sveltejs/kit";
export async function userId(event: RequestEvent): Promise<number> {
const sess = await event.locals.getSession();
const id = Number(sess?.user?.id);
if (id) {
return id;
} else {
// This should never happen, since unauthorized requests are caught by hooks.server.ts
throw new Error("no user id");
}
}

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { hexToColor, colorToHex } from "./colors";
describe.each([
{
hex: "#ffffff",
color: { r: 255, g: 255, b: 255 },
},
{
hex: "#000000",
color: { r: 0, g: 0, b: 0 },
},
{
hex: "#ff0000",
color: { r: 255, g: 0, b: 0 },
},
{
hex: "#00ff00",
color: { r: 0, g: 255, b: 0 },
},
{
hex: "#0000ff",
color: { r: 0, g: 0, b: 255 },
},
{
hex: "#8ECAE6",
color: { r: 142, g: 202, b: 230 },
},
{
hex: "#219EBC",
color: { r: 33, g: 158, b: 188 },
},
{
hex: "#023047",
color: { r: 2, g: 48, b: 71 },
},
{
hex: "#FFB703",
color: { r: 255, g: 183, b: 3 },
},
{
hex: "#FB8500",
color: { r: 251, g: 133, b: 0 },
},
])("color conversion", ({ hex, color }) => {
it("colorToHex", () => {
expect(colorToHex(color)).toBe(hex.toLowerCase());
});
it("hexToColor", () => {
expect(hexToColor(hex)).toStrictEqual(color);
});
});

View file

@ -0,0 +1,72 @@
// Source: https://css-tricks.com/nailing-the-perfect-contrast-between-light-text-and-a-background-image/
/**
* RGB color value (0-255)
*/
export type Color = {
r: number;
g: number;
b: number;
};
const WHITE: Color = { r: 255, g: 255, b: 255 };
const BLACK: Color = { r: 0, g: 0, b: 0 };
export function colorToHex(c: Color): string {
return "#" + ((1 << 24) | (c.r << 16) | (c.g << 8) | c.b).toString(16).slice(1);
}
export function hexToColor(s: string): Color {
const hexStr = s[0] === "#" ? s.substring(1) : s;
const c = parseInt(hexStr, 16);
return {
r: c >> 16,
g: (c >> 8) & 255,
b: c & 255,
};
}
function getContrast(color1: Color, color2: Color): number {
const color1_luminance = getLuminance(color1);
const color2_luminance = getLuminance(color2);
const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);
const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);
const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
return contrast;
}
function getLuminance(c: Color): number {
return (
0.2126 * getLinearRGB(c.r) + 0.7152 * getLinearRGB(c.g) + 0.0722 * getLinearRGB(c.b)
);
}
function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit: number): number {
return primaryColor_8bit / 255;
}
function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB: number): number {
const primaryColor_linear =
primaryColor_sRGB < 0.03928
? primaryColor_sRGB / 12.92
: Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
return primaryColor_linear;
}
function getLinearRGB(primaryColor_8bit: number): number {
// First convert from 8-bit rbg (0-255) to standard RGB (0-1)
const primaryColor_sRGB = convert_8bit_RGB_to_standard_RGB(primaryColor_8bit);
// Then convert from sRGB to linear RGB so we can use it to calculate luminance
const primaryColor_RGB_linear = convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);
return primaryColor_RGB_linear;
}
/**
* Get the preferred text color for the given background color
*/
export function getTextColor(bgColor: Color): Color {
const cWhite = getContrast(bgColor, WHITE);
const cBlack = getContrast(bgColor, BLACK);
return cWhite > cBlack ? WHITE : BLACK;
}

View file

@ -0,0 +1,110 @@
import { test, expect } from "vitest";
import type { EntryVersion } from "$lib/shared/model";
import { CATEGORIES, U1, U2 } from "$tests/helpers/testdata";
import { versionsDiff } from "./diff";
test("versions diff", () => {
const versions: EntryVersion[] = [
{
id: 1,
author: U1,
category: CATEGORIES[0],
created_at: new Date(Date.UTC(2024, 1, 10, 10, 30)),
date: "2024-01-11",
priority: false,
text: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ getestet werden.",
},
{
id: 2,
author: U2,
category: CATEGORIES[0],
created_at: new Date(Date.UTC(2024, 1, 10, 11, 30)),
date: "2024-01-12",
priority: true,
text: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
{
id: 3,
author: U1,
category: CATEGORIES[0],
created_at: new Date(Date.UTC(2024, 1, 10, 11, 31)),
date: "2024-01-12",
priority: true,
text: "10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
];
const diff = versionsDiff(versions);
expect(diff).toStrictEqual([
{
id: 3,
author: {
id: 1,
name: "Sven Schulz",
email: "sven.schulz@example.com",
},
category: {
id: 1,
name: "Laborabnahme",
description: "Blutabnahme zur Untersuchung im Labor",
color: "FF0000",
},
created_at: new Date("2024-02-10T11:31:00.000Z"),
date: "2024-01-12",
priority: true,
text: [
{
count: 38,
added: true,
removed: undefined,
value:
"10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
],
},
{
id: 2,
author: {
id: 2,
name: "Sabrina Loewe",
email: "sabrina.loewe@example.com",
},
created_at: new Date("2024-02-10T11:30:00.000Z"),
category: undefined,
date: undefined,
priority: undefined,
text: [
{ count: 16, value: "10ml Blut abnehmen.\n\nDas Blut muss auf " },
{ count: 1, added: undefined, removed: true, value: "Lambda" },
{ count: 1, added: true, removed: undefined, value: "XYZ" },
{
count: 21,
value: "-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
],
},
{
id: 1,
author: {
id: 1,
name: "Sven Schulz",
email: "sven.schulz@example.com",
},
created_at: new Date("2024-02-10T10:30:00.000Z"),
category: undefined,
text: [
{ count: 17, value: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ" },
{ count: 2, added: undefined, removed: true, value: "-Erreger" },
{ count: 5, value: " getestet werden." },
{
count: 14,
added: undefined,
removed: true,
value: "\n\nHierfür ist das Labor Meier zuständig.",
},
],
date: "2024-01-11",
priority: false,
},
]);
});

View file

@ -0,0 +1,46 @@
import { diffWords } from "diff";
import type { EntryVersion, Category, Option, UserTag } from "$lib/shared/model";
export type EntryVersionChange = {
id: number;
author: UserTag;
created_at: Date;
text: Diff.Change[];
date?: string;
category?: Option<Category>;
priority?: boolean;
};
function newOrUndef<T>(o: T, n: T): T | undefined {
return o === n ? undefined : n;
}
export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] {
let prev = versions[versions.length - 1];
const changes: EntryVersionChange[] = [
{
...prev,
text: diffWords("", prev.text),
},
];
for (let i = versions.length - 2; i >= 0; i--) {
const v = versions[i];
const text = diffWords(prev.text, v.text);
changes.push({
id: v.id,
author: v.author,
created_at: v.created_at,
text,
date: newOrUndef(prev.date, v.date),
category: prev.category?.id === v.category?.id ? undefined : v.category,
priority: newOrUndef(prev.priority, v.priority),
});
prev = v;
}
return changes;
}

View file

@ -2,6 +2,7 @@ import { goto } from "$app/navigation";
import type { EntityQuery } from "$lib/shared/model"; import type { EntityQuery } from "$lib/shared/model";
import { TRPCClientError } from "@trpc/client"; import { TRPCClientError } from "@trpc/client";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { ZodError } from "zod";
export function formatDate(date: Date | string, time = false): string { export function formatDate(date: Date | string, time = false): string {
let dt = date; let dt = date;
@ -25,6 +26,10 @@ export function formatDate(date: Date | string, time = false): string {
} }
} }
export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein";
}
export function getQueryUrl(q: EntityQuery, basePath: string): string { export function getQueryUrl(q: EntityQuery, basePath: string): string {
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath; if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "/" + JSON.stringify(q); return basePath + "/" + JSON.stringify(q);
@ -56,6 +61,8 @@ export async function loadWrap<T>(f: () => Promise<T>) {
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError) { if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message); error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof ZodError) {
error(400, e.message);
} else if (e instanceof Error) { } else if (e instanceof Error) {
error(500, e.message); error(500, e.message);
} else { } else {

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import NavLink from "$lib/components/ui/NavLink.svelte"; import NavLink from "$lib/components/ui/NavLink.svelte";
import { signOut } from "@auth/sveltekit/client";
import { mdiAccount, mdiHome } from "@mdi/js"; import { mdiAccount, mdiHome } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";

View file

@ -5,6 +5,9 @@
import RoomField from "$lib/components/table/RoomField.svelte"; import RoomField from "$lib/components/table/RoomField.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte"; import CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "$lib/components/ui/Markdown.svelte"; import Markdown from "$lib/components/ui/Markdown.svelte";
import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { mdiHistory } from "@mdi/js";
export let data: PageData; export let data: PageData;
</script> </script>
@ -13,50 +16,57 @@
<title>Eintrag #{data.entry.id}</title> <title>Eintrag #{data.entry.id}</title>
</svelte:head> </svelte:head>
<h1 class="heading"> <Header title="Eintrag #{data.entry.id}">
Eintrag #{data.entry.id}
{#if data.entry.current_version.category} {#if data.entry.current_version.category}
<CategoryField category={data.entry.current_version.category} /> <CategoryField category={data.entry.current_version.category} />
{/if} {/if}
</h1> {#if data.entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div>
{/if}
<div class="box"> <a
<p> href="/entry/{data.entry.id}/versions"
class="btn btn-sm btn-primary ml-auto"
slot="rightBtn"
>
<Icon path={mdiHistory} />
<span class="hidden sm:inline">Versionen</span>
</a>
</Header>
<div class="card2">
<div class="row c-light text-sm">Patient</div>
<div class="row items-center gap-2">
{#if data.entry.patient.room} {#if data.entry.patient.room}
<RoomField room={data.entry.patient.room} /> <RoomField room={data.entry.patient.room} />
{/if} {/if}
{data.entry.patient.first_name} {data.entry.patient.first_name}
{data.entry.patient.last_name} {data.entry.patient.last_name}
({data.entry.patient.age}) ({data.entry.patient.age})
</p> </div>
</div> </div>
<div class="box"> <div class="card2">
<p class="prose"> <div class="row c-light text-sm">Beschreibung</div>
<Markdown src={data.entry.current_version.text} /> <div class="row">
</p> <p class="prose">
<Markdown src={data.entry.current_version.text} />
</p>
</div>
</div> </div>
{#if data.entry.execution} {#if data.entry.execution}
<div class="box"> <div class="card2">
<p> <div class="row c-light text-sm">
Erledigt am {formatDate(data.entry.execution.created_at, true)} von <p>
<UserField user={data.entry.execution.author} filterName="executor" />: Erledigt am {formatDate(data.entry.execution.created_at, true)} von
</p> <UserField user={data.entry.execution.author} filterName="executor" />:
</p>
<p class="prose"> </div>
<Markdown src={data.entry.execution?.text} /> <div class="row">
</p> <p class="prose">
<Markdown src={data.entry.execution?.text} />
</p>
</div>
</div> </div>
{/if} {/if}
<style lang="postcss">
p {
@apply my-2;
}
.box {
@apply rounded-xl border-solid border-2 border-base-content/40;
@apply px-2 my-4;
}
</style>

View file

@ -4,8 +4,10 @@ import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id); const entry = await loadWrap(async () => {
const entry = await loadWrap(async () => trpc(event).entry.get.query(id)); const id = ZUrlEntityId.parse(event.params.id);
return trpc(event).entry.get.query(id);
});
return { entry }; return { entry };
}; };

View file

@ -0,0 +1,71 @@
<script lang="ts">
import CategoryField from "$lib/components/table/CategoryField.svelte";
import UserField from "$lib/components/table/UserField.svelte";
import { formatBool, formatDate } from "$lib/shared/util";
import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte";
import { page } from "$app/stores";
export let data: PageData;
$: entryId = $page.params.id;
</script>
<svelte:head>
<title>Eintrag #{entryId} - Versionen</title>
</svelte:head>
<Header title="Eintrag #{entryId} - Versionen" backHref="/entry/{entryId}" />
<div class="overflow-x-auto">
{#each data.versions as version}
<div class="card2">
<div class="row c-light text-sm">
<UserField user={version.author} />, {formatDate(version.created_at, true)}
</div>
{#if version.category}
<div class="row">
<div>Kategeorie</div>
<div><CategoryField category={version.category} /></div>
</div>
{/if}
{#if version.text.length > 0}
<div class="row">
<div>Text</div>
<div class="whitespace-pre-wrap">
{#each version.text as change}
<span class:added={change.added} class:removed={change.removed}>
{change.value}
</span>
{/each}
</div>
</div>
{/if}
{#if version.date !== undefined}
<div class="row">
<div>Datum</div>
<div>{formatDate(version.date)}</div>
</div>
{/if}
{#if version.priority !== undefined}
<div class="row">
<div>Priorität</div>
<div>{formatBool(version.priority)}</div>
</div>
{/if}
</div>
{/each}
</div>
<style lang="postcss">
.row > div:first-child {
min-width: 6.5rem;
}
.added {
@apply bg-success/20;
}
.removed {
@apply bg-error/20;
}
</style>

View file

@ -0,0 +1,11 @@
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id);
const versions = await loadWrap(async () => trpc(event).entry.versionsDiff.query(id));
return { versions };
};

View file

@ -6,6 +6,6 @@ export const actions: Actions = {
default: async (event) => { default: async (event) => {
const callbackUrl = baseUrl(event.url) + "/login?noAuto=1"; const callbackUrl = baseUrl(event.url) + "/login?noAuto=1";
return makeAuthjsRequest(event, "/auth/signout", { callbackUrl }); return makeAuthjsRequest(event, "signout", { callbackUrl });
}, },
}; };

View file

@ -2,6 +2,6 @@ import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async (event) => { export const load: LayoutServerLoad = async (event) => {
return { return {
session: await event.locals.getSession(), session: event.locals.session,
}; };
}; };

View file

@ -1,11 +1,40 @@
import type { Actions } from "./$types"; import type { Actions, RequestEvent } from "./$types";
import { makeAuthjsRequest } from "$lib/server/auth"; import { makeAuthjsRequest } from "$lib/server/auth";
import { baseUrl } from "$lib/shared/util"; import { baseUrl } from "$lib/shared/util";
import type { PageServerLoad } from "./$types";
/**
* This cookie stores the timestamp of the last automatic login.
* It is used to prevent infinite redirect loops in case the authentication fails.
*/
const COOKIE_NAME = "autoLoginTs";
async function doLogin(event: RequestEvent) {
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
return makeAuthjsRequest(event, "signin/keycloak", { callbackUrl });
}
// Try to login the user using OIDC
export const load: PageServerLoad = async (event) => {
// the noAuto URL parameter disables auto-login
if (event.url.searchParams.get("noAuto")) return;
let autoLoginTs = null;
const autoLoginTsStr = event.cookies.get(COOKIE_NAME);
if (autoLoginTsStr) {
autoLoginTs = parseInt(autoLoginTsStr);
}
const now = Date.now();
if (!autoLoginTs || autoLoginTs + 15000 < now) {
event.cookies.set(COOKIE_NAME, now.toString(), { path: "/login" });
return doLogin(event);
}
};
export const actions: Actions = { export const actions: Actions = {
default: async (event) => { default: async (event) => {
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url); return doLogin(event);
return makeAuthjsRequest(event, "/auth/signin/keycloak", { callbackUrl });
}, },
}; };

View file

@ -1,37 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { signIn } from "@auth/sveltekit/client";
import { goto } from "$app/navigation";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
const STORAGE_KEY = "autoLoginTs";
let callbackUrl: string;
$: if ($page.url) {
let u = new URL($page.url.protocol + "//" + $page.url.host);
u.pathname = $page.url.searchParams.get("returnURL") ?? "/";
callbackUrl = u.toString();
}
// Client side auto-login / redirect
onMount(() => {
if (!$page.url.searchParams.get("noAuto")) {
// Keep track of last login time to avoid redirect loop if the login fails
const lastUpdateStr = sessionStorage.getItem(STORAGE_KEY);
const lastUpdate = lastUpdateStr ? parseInt(lastUpdateStr) : null;
const now = Date.now();
if (!lastUpdate || lastUpdate + 10000 < now) {
sessionStorage.setItem(STORAGE_KEY, now.toString());
if (!$page.data.session?.user) {
signIn("keycloak", { callbackUrl });
} else {
goto(callbackUrl);
}
}
}
});
</script> </script>
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center"> <div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">