Compare commits
8 commits
d9167dcd47
...
22b74987e5
Author | SHA1 | Date | |
---|---|---|---|
22b74987e5 | |||
a430f55b2e | |||
2b6b3338d7 | |||
f79882ebf9 | |||
3f0eba7a22 | |||
e60a5b0162 | |||
39973c0a83 | |||
0a7aaed60d |
30 changed files with 1254 additions and 400 deletions
|
@ -15,6 +15,14 @@ npm run dev
|
|||
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
|
||||
|
||||
To create a production version of your app:
|
||||
|
|
40
package.json
40
package.json
|
@ -18,49 +18,53 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.27.0",
|
||||
"@auth/sveltekit": "^0.13.0",
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@prisma/client": "^5.9.1",
|
||||
"isomorphic-dompurify": "^2.3.0",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"diff": "^5.2.0",
|
||||
"isomorphic-dompurify": "^2.4.0",
|
||||
"marked": "^12.0.0",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"zod": "^3.22.4",
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@playwright/test": "^1.42.0",
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"@sveltejs/kit": "^2.5.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@trpc/client": "^10.45.1",
|
||||
"@trpc/server": "^10.45.1",
|
||||
"@types/node": "^20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||
"@typescript-eslint/parser": "^7.0.1",
|
||||
"@types/diff": "^5.0.9",
|
||||
"@types/node": "^20.11.21",
|
||||
"@types/set-cookie-parser": "^2.4.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"daisyui": "^4.7.2",
|
||||
"eslint": "^8.56.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-no-relative-import-paths": "^1.5.3",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"postcss-import": "^16.0.1",
|
||||
"postcss-nesting": "^12.0.2",
|
||||
"postcss-nesting": "^12.0.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"prisma": "^5.9.1",
|
||||
"svelte": "^4.2.11",
|
||||
"svelte-check": "^3.6.4",
|
||||
"sveltekit-superforms": "^2.3.0",
|
||||
"prettier-plugin-svelte": "^3.2.2",
|
||||
"prisma": "^5.10.2",
|
||||
"svelte": "^4.2.12",
|
||||
"svelte-check": "^3.6.5",
|
||||
"sveltekit-superforms": "^2.6.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"trpc-sveltekit": "^3.5.26",
|
||||
"trpc-sveltekit": "^3.5.27",
|
||||
"tslib": "^2.6.2",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.3",
|
||||
"vitest": "^1.3.0"
|
||||
"vite": "^5.1.4",
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
897
pnpm-lock.yaml
897
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -33,5 +33,5 @@ EXECUTE PROCEDURE update_entry_tsvec ();
|
|||
ALTER TABLE patients
|
||||
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);
|
||||
|
|
|
@ -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;
|
|
@ -108,6 +108,7 @@ model Entry {
|
|||
tsvec Unsupported("tsvector")?
|
||||
|
||||
@@map("entries")
|
||||
@@index([tsvec], type: Gin)
|
||||
}
|
||||
|
||||
model EntryVersion {
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
set -e
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
echo 'Waiting for database to be ready...'
|
||||
"$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!'
|
||||
|
||||
# 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/../"
|
||||
DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force
|
||||
|
||||
|
|
6
src/app.d.ts
vendored
6
src/app.d.ts
vendored
|
@ -1,9 +1,13 @@
|
|||
import type { Session } from "@auth/core/types";
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
session: Session;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
|
33
src/app.pcss
33
src/app.pcss
|
@ -17,6 +17,10 @@ button {
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.border-1 {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.v-form-field > input {
|
||||
@apply input input-bordered w-full max-w-xs;
|
||||
}
|
||||
|
@ -25,3 +29,32 @@ button {
|
|||
.v-form-field > [aria-invalid="true"] {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,10 @@ import { skAuthHandle } from "$lib/server/auth";
|
|||
* auth mechanism)
|
||||
*/
|
||||
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)) {
|
||||
const session = await event.locals.getSession();
|
||||
if (!session) {
|
||||
if (!event.locals.session) {
|
||||
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
||||
redirect(303, "/login?" + params.toString());
|
||||
}
|
||||
|
|
|
@ -2,10 +2,15 @@
|
|||
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||
import type { Category } from "$lib/shared/model";
|
||||
import { gotoEntityQuery } from "$lib/shared/util";
|
||||
import { getTextColor, colorToHex, hexToColor } from "$lib/shared/util/colors";
|
||||
|
||||
export let category: Category;
|
||||
export let baseUrl = URL_ENTRIES;
|
||||
|
||||
$: textColor = category.color
|
||||
? colorToHex(getTextColor(hexToColor(category.color)))
|
||||
: null;
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
gotoEntityQuery(
|
||||
{
|
||||
|
@ -22,6 +27,8 @@
|
|||
<button
|
||||
class="badge ellipsis"
|
||||
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
|
||||
>
|
||||
|
|
23
src/lib/components/ui/Header.svelte
Normal file
23
src/lib/components/ui/Header.svelte
Normal 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>
|
|
@ -1,12 +1,21 @@
|
|||
import { SvelteKitAuth, type SvelteKitAuthConfig } from "@auth/sveltekit";
|
||||
import { Auth, raw, skipCSRFCheck } from "@auth/core";
|
||||
import {
|
||||
Auth,
|
||||
isAuthAction,
|
||||
raw,
|
||||
setEnvDefaults,
|
||||
skipCSRFCheck,
|
||||
type AuthConfig,
|
||||
} from "@auth/core";
|
||||
import Keycloak from "@auth/core/providers/keycloak";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { PrismaAdapter } from "$lib/server/authAdapter";
|
||||
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),
|
||||
providers: [
|
||||
Keycloak({
|
||||
|
@ -36,10 +45,26 @@ export const AUTH_CFG: SvelteKitAuthConfig = {
|
|||
return opt.session;
|
||||
},
|
||||
},
|
||||
|
||||
basePath: AUTH_BASE_PATH,
|
||||
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(
|
||||
event: RequestEvent,
|
||||
authjsEndpoint: string,
|
||||
|
@ -48,11 +73,8 @@ export async function makeAuthjsRequest(
|
|||
const headers = new Headers(event.request.headers);
|
||||
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 req = new Request(baseUrl + authjsEndpoint, {
|
||||
const req = new Request(authjsUrl(event, authjsEndpoint), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
|
@ -64,4 +86,38 @@ export async function makeAuthjsRequest(
|
|||
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);
|
||||
};
|
||||
|
|
|
@ -6,13 +6,11 @@ import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
|
|||
// hence the eslint-disable rule
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function createContext(event: RequestEvent) {
|
||||
const session = await event.locals.getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
if (!event.locals.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
|
||||
}
|
||||
|
||||
const user = ZUser.parse(session?.user);
|
||||
const user = ZUser.parse(event.locals.session?.user);
|
||||
return {
|
||||
user,
|
||||
};
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
newEntryExecution,
|
||||
newEntryVersion,
|
||||
} from "$lib/server/query";
|
||||
import { versionsDiff } from "$lib/shared/util/diff";
|
||||
|
||||
const ZEntityId = fields.EntityId();
|
||||
|
||||
|
@ -38,6 +39,12 @@ export const entryRouter = t.router({
|
|||
versions: t.procedure
|
||||
.input(ZEntityId)
|
||||
.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
|
||||
.input(ZEntityId)
|
||||
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
52
src/lib/shared/util/colors.test.ts
Normal file
52
src/lib/shared/util/colors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
72
src/lib/shared/util/colors.ts
Normal file
72
src/lib/shared/util/colors.ts
Normal 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;
|
||||
}
|
110
src/lib/shared/util/diff.test.ts
Normal file
110
src/lib/shared/util/diff.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
46
src/lib/shared/util/diff.ts
Normal file
46
src/lib/shared/util/diff.ts
Normal 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;
|
||||
}
|
|
@ -2,6 +2,7 @@ import { goto } from "$app/navigation";
|
|||
import type { EntityQuery } from "$lib/shared/model";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function formatDate(date: Date | string, time = false): string {
|
||||
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 {
|
||||
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
|
||||
return basePath + "/" + JSON.stringify(q);
|
||||
|
@ -56,6 +61,8 @@ export async function loadWrap<T>(f: () => Promise<T>) {
|
|||
} catch (e) {
|
||||
if (e instanceof TRPCClientError) {
|
||||
error(e.data?.httpStatus ?? 500, e.message);
|
||||
} else if (e instanceof ZodError) {
|
||||
error(400, e.message);
|
||||
} else if (e instanceof Error) {
|
||||
error(500, e.message);
|
||||
} else {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import NavLink from "$lib/components/ui/NavLink.svelte";
|
||||
import { signOut } from "@auth/sveltekit/client";
|
||||
import { mdiAccount, mdiHome } from "@mdi/js";
|
||||
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
import RoomField from "$lib/components/table/RoomField.svelte";
|
||||
import CategoryField from "$lib/components/table/CategoryField.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;
|
||||
</script>
|
||||
|
@ -13,50 +16,57 @@
|
|||
<title>Eintrag #{data.entry.id}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="heading">
|
||||
Eintrag #{data.entry.id}
|
||||
<Header title="Eintrag #{data.entry.id}">
|
||||
{#if data.entry.current_version.category}
|
||||
<CategoryField category={data.entry.current_version.category} />
|
||||
{/if}
|
||||
</h1>
|
||||
{#if data.entry.current_version.priority}
|
||||
<div class="badge ellipsis badge-warning">Priorität</div>
|
||||
{/if}
|
||||
|
||||
<div class="box">
|
||||
<p>
|
||||
<a
|
||||
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}
|
||||
<RoomField room={data.entry.patient.room} />
|
||||
{/if}
|
||||
{data.entry.patient.first_name}
|
||||
{data.entry.patient.last_name}
|
||||
({data.entry.patient.age})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="prose">
|
||||
<Markdown src={data.entry.current_version.text} />
|
||||
</p>
|
||||
<div class="card2">
|
||||
<div class="row c-light text-sm">Beschreibung</div>
|
||||
<div class="row">
|
||||
<p class="prose">
|
||||
<Markdown src={data.entry.current_version.text} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.entry.execution}
|
||||
<div class="box">
|
||||
<p>
|
||||
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
|
||||
<UserField user={data.entry.execution.author} filterName="executor" />:
|
||||
</p>
|
||||
|
||||
<p class="prose">
|
||||
<Markdown src={data.entry.execution?.text} />
|
||||
</p>
|
||||
<div class="card2">
|
||||
<div class="row c-light text-sm">
|
||||
<p>
|
||||
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
|
||||
<UserField user={data.entry.execution.author} filterName="executor" />:
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p class="prose">
|
||||
<Markdown src={data.entry.execution?.text} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/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>
|
||||
|
|
|
@ -4,8 +4,10 @@ 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 entry = await loadWrap(async () => trpc(event).entry.get.query(id));
|
||||
const entry = await loadWrap(async () => {
|
||||
const id = ZUrlEntityId.parse(event.params.id);
|
||||
return trpc(event).entry.get.query(id);
|
||||
});
|
||||
|
||||
return { entry };
|
||||
};
|
||||
|
|
71
src/routes/(app)/entry/[id]/versions/+page.svelte
Normal file
71
src/routes/(app)/entry/[id]/versions/+page.svelte
Normal 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>
|
11
src/routes/(app)/entry/[id]/versions/+page.ts
Normal file
11
src/routes/(app)/entry/[id]/versions/+page.ts
Normal 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 };
|
||||
};
|
|
@ -6,6 +6,6 @@ export const actions: Actions = {
|
|||
default: async (event) => {
|
||||
const callbackUrl = baseUrl(event.url) + "/login?noAuto=1";
|
||||
|
||||
return makeAuthjsRequest(event, "/auth/signout", { callbackUrl });
|
||||
return makeAuthjsRequest(event, "signout", { callbackUrl });
|
||||
},
|
||||
};
|
||||
|
|
|
@ -2,6 +2,6 @@ import type { LayoutServerLoad } from "./$types";
|
|||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
return {
|
||||
session: await event.locals.getSession(),
|
||||
session: event.locals.session,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,11 +1,40 @@
|
|||
import type { Actions } from "./$types";
|
||||
import type { Actions, RequestEvent } from "./$types";
|
||||
import { makeAuthjsRequest } from "$lib/server/auth";
|
||||
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 = {
|
||||
default: async (event) => {
|
||||
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
|
||||
|
||||
return makeAuthjsRequest(event, "/auth/signin/keycloak", { callbackUrl });
|
||||
return doLogin(event);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,37 +1,5 @@
|
|||
<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";
|
||||
|
||||
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>
|
||||
|
||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
||||
|
|
Loading…
Reference in a new issue