Compare commits
9 commits
2455b90d64
...
ea07f1a092
Author | SHA1 | Date | |
---|---|---|---|
ea07f1a092 | |||
88a512a341 | |||
b12d5cf3f9 | |||
19d5e0d798 | |||
20829ea97f | |||
30bfc49d0f | |||
de041b2f02 | |||
77bfb6c383 | |||
fdf8b0d667 |
46 changed files with 2269 additions and 295 deletions
8
.editorconfig
Normal file
8
.editorconfig
Normal file
|
@ -0,0 +1,8 @@
|
|||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 88
|
|
@ -1,23 +1,13 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:svelte/recommended",
|
||||
"prettier",
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: [".svelte"],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true,
|
||||
},
|
||||
plugins: ["@typescript-eslint", "no-relative-import-paths"],
|
||||
ignorePatterns: ["*.cjs"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.svelte"],
|
||||
|
@ -27,4 +17,22 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
],
|
||||
settings: {},
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
"no-relative-import-paths/no-relative-import-paths": [
|
||||
"warn",
|
||||
{ allowSameFolder: true },
|
||||
],
|
||||
"no-console": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
|
||||
},
|
||||
};
|
||||
|
|
28
package.json
28
package.json
|
@ -7,24 +7,27 @@
|
|||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:integration && npm run test:unit",
|
||||
"test": "vitest --run && vitest --config vitest.config.integration.ts --run",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:integration": "playwright test",
|
||||
"test:unit": "vitest"
|
||||
"test:unit": "vitest",
|
||||
"test:integration": "vitest --config vitest.config.integration.ts",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.18.4",
|
||||
"@auth/prisma-adapter": "^1.0.9",
|
||||
"@auth/sveltekit": "^0.3.15",
|
||||
"@prisma/client": "^5.7.0"
|
||||
"@auth/sveltekit": "^0.5.0",
|
||||
"@mdi/js": "^7.3.67",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@sveltejs/adapter-auto": "^2.1.1",
|
||||
"@sveltejs/kit": "^1.27.7",
|
||||
"@sveltejs/adapter-node": "^2.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/node": "^20.10.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
||||
|
@ -33,9 +36,10 @@
|
|||
"daisyui": "^4.4.19",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-no-relative-import-paths": "^1.5.3",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-load-config": "^4.0.2",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"prettier": "^3.1.0",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"prisma": "^5.7.0",
|
||||
|
@ -44,8 +48,8 @@
|
|||
"tailwindcss": "^3.3.6",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^4.5.1",
|
||||
"vitest": "^0.32.4"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
779
pnpm-lock.yaml
779
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,13 +1,8 @@
|
|||
const tailwindcss = require("tailwindcss");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
|
||||
const config = {
|
||||
plugins: [
|
||||
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
|
||||
tailwindcss(),
|
||||
//But others, like autoprefixer, need to run after,
|
||||
autoprefixer,
|
||||
],
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"tailwindcss/nesting": "postcss-nesting",
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
@ -59,6 +59,8 @@ CREATE TABLE "patients" (
|
|||
CREATE TABLE "categories" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
@ -90,6 +92,7 @@ CREATE TABLE "entry_versions" (
|
|||
CREATE TABLE "entry_execution" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"entryId" INTEGER NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"authorId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
@ -85,6 +85,8 @@ model Patient {
|
|||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
color String?
|
||||
description String?
|
||||
EntryVersion EntryVersion[]
|
||||
|
||||
@@map("categories")
|
||||
|
@ -128,6 +130,8 @@ model EntryExecution {
|
|||
entry Entry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||||
entryId Int
|
||||
|
||||
text String
|
||||
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
authorId Int
|
||||
|
||||
|
|
1
run/.gitignore
vendored
Normal file
1
run/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
postgres
|
12
run/db_up.sh
Executable file
12
run/db_up.sh
Executable file
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
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'
|
||||
cd "$DIR/../" && DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force
|
182
run/wait-for-it.sh
Executable file
182
run/wait-for-it.sh
Executable file
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env bash
|
||||
# Use this script to test if a given TCP host/port are available
|
||||
|
||||
WAITFORIT_cmdname=${0##*/}
|
||||
|
||||
echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
|
||||
|
||||
usage()
|
||||
{
|
||||
cat << USAGE >&2
|
||||
Usage:
|
||||
$WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
|
||||
-h HOST | --host=HOST Host or IP under test
|
||||
-p PORT | --port=PORT TCP port under test
|
||||
Alternatively, you specify the host and port as host:port
|
||||
-s | --strict Only execute subcommand if the test succeeds
|
||||
-q | --quiet Don't output any status messages
|
||||
-t TIMEOUT | --timeout=TIMEOUT
|
||||
Timeout in seconds, zero for no timeout
|
||||
-- COMMAND ARGS Execute command with args after the test finishes
|
||||
USAGE
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for()
|
||||
{
|
||||
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
|
||||
echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
|
||||
else
|
||||
echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
|
||||
fi
|
||||
WAITFORIT_start_ts=$(date +%s)
|
||||
while :
|
||||
do
|
||||
if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
|
||||
nc -z $WAITFORIT_HOST $WAITFORIT_PORT
|
||||
WAITFORIT_result=$?
|
||||
else
|
||||
(echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
|
||||
WAITFORIT_result=$?
|
||||
fi
|
||||
if [[ $WAITFORIT_result -eq 0 ]]; then
|
||||
WAITFORIT_end_ts=$(date +%s)
|
||||
echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return $WAITFORIT_result
|
||||
}
|
||||
|
||||
wait_for_wrapper()
|
||||
{
|
||||
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
|
||||
if [[ $WAITFORIT_QUIET -eq 1 ]]; then
|
||||
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
|
||||
else
|
||||
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
|
||||
fi
|
||||
WAITFORIT_PID=$!
|
||||
trap "kill -INT -$WAITFORIT_PID" INT
|
||||
wait $WAITFORIT_PID
|
||||
WAITFORIT_RESULT=$?
|
||||
if [[ $WAITFORIT_RESULT -ne 0 ]]; then
|
||||
echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
|
||||
fi
|
||||
return $WAITFORIT_RESULT
|
||||
}
|
||||
|
||||
# process arguments
|
||||
while [[ $# -gt 0 ]]
|
||||
do
|
||||
case "$1" in
|
||||
*:* )
|
||||
WAITFORIT_hostport=(${1//:/ })
|
||||
WAITFORIT_HOST=${WAITFORIT_hostport[0]}
|
||||
WAITFORIT_PORT=${WAITFORIT_hostport[1]}
|
||||
shift 1
|
||||
;;
|
||||
--child)
|
||||
WAITFORIT_CHILD=1
|
||||
shift 1
|
||||
;;
|
||||
-q | --quiet)
|
||||
WAITFORIT_QUIET=1
|
||||
shift 1
|
||||
;;
|
||||
-s | --strict)
|
||||
WAITFORIT_STRICT=1
|
||||
shift 1
|
||||
;;
|
||||
-h)
|
||||
WAITFORIT_HOST="$2"
|
||||
if [[ $WAITFORIT_HOST == "" ]]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--host=*)
|
||||
WAITFORIT_HOST="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
-p)
|
||||
WAITFORIT_PORT="$2"
|
||||
if [[ $WAITFORIT_PORT == "" ]]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--port=*)
|
||||
WAITFORIT_PORT="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
-t)
|
||||
WAITFORIT_TIMEOUT="$2"
|
||||
if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--timeout=*)
|
||||
WAITFORIT_TIMEOUT="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
WAITFORIT_CLI=("$@")
|
||||
break
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echoerr "Unknown argument: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
|
||||
echoerr "Error: you need to provide a host and port to test."
|
||||
usage
|
||||
fi
|
||||
|
||||
WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
|
||||
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
|
||||
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
|
||||
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
|
||||
|
||||
# Check to see if timeout is from busybox?
|
||||
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
|
||||
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
|
||||
|
||||
WAITFORIT_BUSYTIMEFLAG=""
|
||||
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
|
||||
WAITFORIT_ISBUSY=1
|
||||
# Check if busybox timeout uses -t flag
|
||||
# (recent Alpine versions don't support -t anymore)
|
||||
if timeout &>/dev/stdout | grep -q -e '-t '; then
|
||||
WAITFORIT_BUSYTIMEFLAG="-t"
|
||||
fi
|
||||
else
|
||||
WAITFORIT_ISBUSY=0
|
||||
fi
|
||||
|
||||
if [[ $WAITFORIT_CHILD -gt 0 ]]; then
|
||||
wait_for
|
||||
WAITFORIT_RESULT=$?
|
||||
exit $WAITFORIT_RESULT
|
||||
else
|
||||
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
|
||||
wait_for_wrapper
|
||||
WAITFORIT_RESULT=$?
|
||||
else
|
||||
wait_for
|
||||
WAITFORIT_RESULT=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $WAITFORIT_CLI != "" ]]; then
|
||||
if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
|
||||
echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
|
||||
exit $WAITFORIT_RESULT
|
||||
fi
|
||||
exec "${WAITFORIT_CLI[@]}"
|
||||
else
|
||||
exit $WAITFORIT_RESULT
|
||||
fi
|
|
@ -1,5 +1,5 @@
|
|||
import { prisma } from "$lib/prisma";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { PrismaAdapter } from "$lib/server/authAdapter";
|
||||
import { SvelteKitAuth } from "@auth/sveltekit";
|
||||
import Keycloak from "@auth/core/providers/keycloak";
|
||||
import {
|
||||
|
@ -19,7 +19,7 @@ const authorization: Handle = async ({ event, resolve }) => {
|
|||
const session = await event.locals.getSession();
|
||||
if (!session) {
|
||||
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
||||
throw redirect(303, "/login?" + params.toString());
|
||||
redirect(303, "/login?" + params.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
59
src/lib/components/ui/Icon.svelte
Normal file
59
src/lib/components/ui/Icon.svelte
Normal file
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
export let path: string;
|
||||
export let size = 1.5;
|
||||
export let color = "currentColor";
|
||||
export let flipV = false;
|
||||
export let flipH = false;
|
||||
export let rotate = 0;
|
||||
export let spin = 0;
|
||||
export let title = "";
|
||||
export let cls = "";
|
||||
|
||||
$: scaleV = flipV ? -1 : 1;
|
||||
$: scaleH = flipH ? -1 : 1;
|
||||
$: absSpin = Math.abs(spin);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
style="--color: {color}; --size: {size}rem; --rotate: {rotate}deg; --scaleV: {scaleV}; --scaleH:
|
||||
{scaleH}; --spin-duration: {absSpin}s;"
|
||||
class={cls}
|
||||
>
|
||||
{#if title}<title>{title}</title>{/if}
|
||||
|
||||
<g class:spin={spin > 0} class:spinReverse={spin < 0}>
|
||||
<path d={path} />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
fill: var(--color);
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
transform: rotate(var(--rotate)) scale(var(--scaleV), var(--scaleH));
|
||||
}
|
||||
/* If spin is strictly > 0, class spin is added to g and plays {spinFrames} in {spin-duration} seconds */
|
||||
g.spin {
|
||||
transform-origin: center;
|
||||
animation: spinFrames linear var(--spin-duration) infinite;
|
||||
}
|
||||
/* If spin is strictly < 0, class spinReverse is added to g and plays {spinReverseFrames} in {spin-duration} seconds */
|
||||
g.spinReverse {
|
||||
transform-origin: center;
|
||||
animation: spinReverseFrames linear var(--spin-duration) infinite;
|
||||
}
|
||||
@keyframes spinFrames {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes spinReverseFrames {
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -2,12 +2,13 @@
|
|||
import { page } from "$app/stores";
|
||||
|
||||
export let route: string;
|
||||
export let href: string;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<a
|
||||
class="btn btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
|
||||
class:underline={$page.route.id === route}
|
||||
href={route}><slot /></a
|
||||
{href}><slot /></a
|
||||
>
|
||||
</div>
|
||||
|
|
83
src/lib/server/authAdapter.ts
Normal file
83
src/lib/server/authAdapter.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import type { Adapter, AdapterAccount, AdapterUser } from "@auth/core/adapters";
|
||||
import type { Account, User, PrismaClient } from "@prisma/client";
|
||||
|
||||
/// Map database user (with numeric ID) to authjs user
|
||||
function mapUser(user: User): AdapterUser {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
name: user.name,
|
||||
email: user.email!,
|
||||
emailVerified: user.emailVerified,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserOpt(user: User | null | undefined): AdapterUser | null {
|
||||
if (!user) return null;
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
function mapAccount(account: Account): AdapterAccount {
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
type: account.type as "oauth" | "oidc" | "email",
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
refresh_token: account.refresh_token ?? undefined,
|
||||
access_token: account.access_token ?? undefined,
|
||||
expires_at: account.expires_at ?? undefined,
|
||||
token_type: account.token_type ?? undefined,
|
||||
scope: account.scope ?? undefined,
|
||||
id_token: account.id_token ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function PrismaAdapter(p: PrismaClient): Adapter {
|
||||
return {
|
||||
createUser: async (data) =>
|
||||
mapUser(
|
||||
await p.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
emailVerified: data.emailVerified,
|
||||
},
|
||||
})
|
||||
),
|
||||
getUser: async (id) =>
|
||||
mapUserOpt(await p.user.findUnique({ where: { id: parseInt(id) } })),
|
||||
getUserByEmail: async (email) =>
|
||||
mapUserOpt(await p.user.findUnique({ where: { email } })),
|
||||
async getUserByAccount(provider_providerAccountId) {
|
||||
const account = await p.account.findUnique({
|
||||
where: { provider_providerAccountId },
|
||||
select: { user: true },
|
||||
});
|
||||
return mapUserOpt(account?.user) ?? null;
|
||||
},
|
||||
updateUser: async ({ id, ...data }) =>
|
||||
mapUser(await p.user.update({ where: { id: parseInt(id) }, data })),
|
||||
deleteUser: async (id) =>
|
||||
mapUser(await p.user.delete({ where: { id: parseInt(id) } })),
|
||||
linkAccount: async (data) =>
|
||||
mapAccount(
|
||||
await p.account.create({
|
||||
data: {
|
||||
userId: parseInt(data.userId),
|
||||
type: data.type,
|
||||
provider: data.provider,
|
||||
providerAccountId: data.providerAccountId,
|
||||
refresh_token: data.refresh_token,
|
||||
access_token: data.access_token,
|
||||
expires_at: data.expires_at,
|
||||
token_type: data.token_type,
|
||||
scope: data.scope,
|
||||
id_token: data.id_token,
|
||||
},
|
||||
})
|
||||
),
|
||||
unlinkAccount: (provider_providerAccountId) =>
|
||||
p.account.delete({
|
||||
where: { provider_providerAccountId },
|
||||
}) as unknown as AdapterAccount,
|
||||
};
|
||||
}
|
|
@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
|||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: ["query"],
|
||||
// log: ["query"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
17
src/lib/server/query/category.ts
Normal file
17
src/lib/server/query/category.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { Category, CategoryNew } from "$lib/shared/model";
|
||||
import { ZCategoryNew } from "$lib/shared/model/validation";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
|
||||
export async function newCategory(category: CategoryNew): Promise<number> {
|
||||
const data = ZCategoryNew.parse(category);
|
||||
const created = await prisma.category.create({ data });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function getCategory(id: number): Promise<Category> {
|
||||
return prisma.category.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getCategories(): Promise<Category[]> {
|
||||
return prisma.category.findMany({ orderBy: { id: "asc" } });
|
||||
}
|
259
src/lib/server/query/entry.ts
Normal file
259
src/lib/server/query/entry.ts
Normal file
|
@ -0,0 +1,259 @@
|
|||
import { prisma } from "$lib/server/prisma";
|
||||
import type {
|
||||
EntriesRequest,
|
||||
Entry,
|
||||
EntryExecutionNew,
|
||||
EntryNew,
|
||||
EntryVersionNew,
|
||||
Pagination,
|
||||
} from "$lib/shared/model";
|
||||
import {
|
||||
ZEntryExecutionNew,
|
||||
ZEntryNew,
|
||||
ZEntryVersionNew,
|
||||
} from "$lib/shared/model/validation";
|
||||
import { mapEntry } from "./mapping";
|
||||
import { QueryBuilder } from "./util";
|
||||
|
||||
const USER_SELECT = { select: { id: true, name: true } };
|
||||
|
||||
export async function getEntry(id: number): Promise<Entry> {
|
||||
const entry = await prisma.entry.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
patient: { include: { room: { include: { station: true } } } },
|
||||
EntryVersion: {
|
||||
include: { author: USER_SELECT, category: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
EntryExecution: {
|
||||
include: { author: USER_SELECT },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mapEntry(entry);
|
||||
}
|
||||
|
||||
export async function newEntry(authorId: number, entry: EntryNew): Promise<number> {
|
||||
const data = ZEntryNew.parse(entry);
|
||||
const created = await prisma.entry.create({
|
||||
data: {
|
||||
patientId: data.patientId,
|
||||
EntryVersion: {
|
||||
create: {
|
||||
authorId,
|
||||
...data.version,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function newEntryVersion(
|
||||
authorId: number,
|
||||
entryId: number,
|
||||
version: EntryVersionNew
|
||||
): Promise<number> {
|
||||
const data = ZEntryVersionNew.parse(version);
|
||||
const created = await prisma.entryVersion.create({
|
||||
data: {
|
||||
entryId,
|
||||
authorId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function newEntryExecution(
|
||||
authorId: number,
|
||||
entryId: number,
|
||||
execution: EntryExecutionNew
|
||||
): Promise<number> {
|
||||
const data = ZEntryExecutionNew.parse(execution);
|
||||
const created = await prisma.entryExecution.create({
|
||||
data: {
|
||||
entryId,
|
||||
authorId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function getEntries(req: EntriesRequest): Promise<Pagination<Entry>> {
|
||||
const qb = new QueryBuilder(
|
||||
`select
|
||||
e.id,
|
||||
e."createdAt",
|
||||
ev.text,
|
||||
ev."date",
|
||||
ev.priority,
|
||||
ev.id as "versionId",
|
||||
ev."createdAt" as "versionCreatedAt",
|
||||
vau.id as "versionAuthorId",
|
||||
vau."name" as "versionAuthorName",
|
||||
c.id as "categoryId",
|
||||
c."name" as "categoryName",
|
||||
c.color as "categoryColor",
|
||||
ex.id as "executionId",
|
||||
ex.text as "executionText",
|
||||
ex."createdAt" as "executionCreatedAt",
|
||||
xau.id as "executionAuthorId",
|
||||
xau.name as "executionAuthorName",
|
||||
p.id as "patientId",
|
||||
p."firstName" as "patientFirstName",
|
||||
p."lastName" as "patientLastName",
|
||||
p.age as "patientAge",
|
||||
p."createdAt" as "patientCreatedAt",
|
||||
r.id as "roomId",
|
||||
r."name" as "roomName",
|
||||
s.id as "stationId",
|
||||
s."name" as "stationName"`,
|
||||
`from entries e
|
||||
join entry_versions ev on
|
||||
ev."entryId" = e.id
|
||||
and ev.id = (
|
||||
select
|
||||
id
|
||||
from
|
||||
entry_versions ev2
|
||||
where
|
||||
ev2."entryId" = ev."entryId"
|
||||
order by
|
||||
ev2."createdAt" desc
|
||||
limit 1)
|
||||
join users vau on vau.id=ev."authorId"
|
||||
left join categories c on c.id=ev."categoryId"
|
||||
left join entry_execution ex on
|
||||
ex."entryId" = e.id
|
||||
and ex.id = (
|
||||
select
|
||||
id
|
||||
from
|
||||
entry_execution ex2
|
||||
where
|
||||
ex2."entryId" = ex."entryId"
|
||||
order by
|
||||
ex2."createdAt" desc
|
||||
limit 1)
|
||||
left join users xau on xau.id=ex."authorId"
|
||||
join patients p on p.id = e."patientId"
|
||||
join rooms r on r.id = p."roomId"
|
||||
join stations s on s.id = r."stationId"`
|
||||
);
|
||||
|
||||
// qb.addFilterIsNull("ex.id", req.filter?.done);
|
||||
if (req.filter?.done === true) {
|
||||
qb.addFilterClause("ex.id is not null");
|
||||
} else if (req.filter?.done === false) {
|
||||
qb.addFilterClause("ex.id is null");
|
||||
}
|
||||
|
||||
qb.addFilterList("xau.id", req.filter?.executor);
|
||||
qb.addFilterList("c.id", req.filter?.category);
|
||||
qb.addFilterList("p.id", req.filter?.patient);
|
||||
qb.addFilterList("s.id", req.filter?.station);
|
||||
qb.addFilterList("r.id", req.filter?.room);
|
||||
qb.addFilter("ev.priority", req.filter?.priority);
|
||||
|
||||
if (req.filter?.author) {
|
||||
let author = req.filter?.author;
|
||||
if (!Array.isArray(author)) {
|
||||
author = [author];
|
||||
}
|
||||
qb.addFilterClause(
|
||||
`${qb.pvar()}::integer[] && (select array_agg(ev2."authorId") from entry_versions ev2 where ev2."entryId"=e.id)`,
|
||||
author
|
||||
);
|
||||
}
|
||||
|
||||
qb.setOrderClause(`order by e."createdAt" desc`);
|
||||
if (req.pagination) qb.setPagination(req.pagination);
|
||||
|
||||
type RequestItem = {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
text: string;
|
||||
date: Date;
|
||||
priority: boolean;
|
||||
versionId: number;
|
||||
versionCreatedAt: Date;
|
||||
versionAuthorId: number;
|
||||
versionAuthorName: string;
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
categoryColor: string;
|
||||
executionId: number;
|
||||
executionText: string;
|
||||
executionCreatedAt: Date;
|
||||
executionAuthorId: number;
|
||||
executionAuthorName: string;
|
||||
patientId: number;
|
||||
patientFirstName: string;
|
||||
patientLastName: string;
|
||||
patientAge: number;
|
||||
patientCreatedAt: Date;
|
||||
roomId: number;
|
||||
roomName: string;
|
||||
stationId: number;
|
||||
stationName: string;
|
||||
};
|
||||
|
||||
const [res, countRes] = (await Promise.all([
|
||||
prisma.$queryRawUnsafe(qb.getQuery(), ...qb.getParams()),
|
||||
prisma.$queryRawUnsafe(qb.getCountQuery(), ...qb.getCountParams()),
|
||||
])) as [RequestItem[], { count: bigint }[]];
|
||||
|
||||
const total = Number(countRes[0].count);
|
||||
|
||||
const items: Entry[] = res.map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
patient: {
|
||||
id: item.patientId,
|
||||
firstName: item.patientFirstName,
|
||||
lastName: item.patientLastName,
|
||||
createdAt: item.patientCreatedAt,
|
||||
age: item.patientAge,
|
||||
room: {
|
||||
id: item.roomId,
|
||||
name: item.roomName,
|
||||
station: { id: item.stationId, name: item.stationName },
|
||||
},
|
||||
},
|
||||
createdAt: item.createdAt,
|
||||
currentVersion: {
|
||||
id: item.versionId,
|
||||
text: item.text,
|
||||
date: item.date,
|
||||
category: item.categoryId
|
||||
? {
|
||||
id: item.categoryId,
|
||||
name: item.categoryName,
|
||||
color: item.categoryColor,
|
||||
description: null,
|
||||
}
|
||||
: null,
|
||||
priority: item.priority,
|
||||
author: { id: item.versionAuthorId, name: item.versionAuthorName },
|
||||
createdAt: item.createdAt,
|
||||
},
|
||||
execution: item.executionId
|
||||
? {
|
||||
id: item.executionId,
|
||||
author: { id: item.executionAuthorId, name: item.executionAuthorName },
|
||||
text: item.executionText,
|
||||
createdAt: item.executionCreatedAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return { items, offset: qb.getOffset(), total };
|
||||
}
|
5
src/lib/server/query/index.ts
Normal file
5
src/lib/server/query/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "./entry";
|
||||
export * from "./category";
|
||||
export * from "./patient";
|
||||
export * from "./user";
|
||||
export * from "./room";
|
83
src/lib/server/query/mapping.ts
Normal file
83
src/lib/server/query/mapping.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import type { Entry, Patient, User, UserTag, Room } from "$lib/shared/model";
|
||||
import type {
|
||||
Patient as DbPatient,
|
||||
Room as DbRoom,
|
||||
Station as DbStation,
|
||||
User as DbUser,
|
||||
Entry as DbEntry,
|
||||
EntryVersion as DbEntryVersion,
|
||||
EntryExecution as DbEntryExecution,
|
||||
Category as DbCategory,
|
||||
} from "@prisma/client";
|
||||
|
||||
type DbRoomLn = DbRoom & { station: DbStation };
|
||||
type DbPatientLn = DbPatient & { room: DbRoomLn | null };
|
||||
type DbEntryVersionLn = DbEntryVersion & {
|
||||
category: DbCategory | null;
|
||||
author: UserTag;
|
||||
};
|
||||
type DbEntryExecutionLn = DbEntryExecution & { author: UserTag };
|
||||
|
||||
export function mapPatient(patient: DbPatientLn): Patient {
|
||||
return {
|
||||
id: patient.id,
|
||||
firstName: patient.firstName,
|
||||
lastName: patient.lastName,
|
||||
createdAt: patient.createdAt,
|
||||
age: patient.age,
|
||||
room: patient.room
|
||||
? {
|
||||
id: patient.room.id,
|
||||
name: patient.room.name,
|
||||
station: patient.room.station,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUser(user: DbUser): User {
|
||||
return { id: user.id, name: user.name, email: user.email };
|
||||
}
|
||||
|
||||
export function mapUserTag(user: DbUser): UserTag {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
|
||||
export function mapRoom(room: DbRoomLn): Room {
|
||||
return { id: room.id, name: room.name, station: room.station };
|
||||
}
|
||||
|
||||
export function mapEntry(
|
||||
entry: DbEntry & {
|
||||
EntryVersion: DbEntryVersionLn[];
|
||||
EntryExecution: DbEntryExecutionLn[];
|
||||
patient: DbPatientLn;
|
||||
}
|
||||
): Entry {
|
||||
const v = entry.EntryVersion[0];
|
||||
if (!v) throw new Error("no version associated with that entry");
|
||||
const x = entry.EntryExecution[0];
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
patient: mapPatient(entry.patient),
|
||||
createdAt: entry.createdAt,
|
||||
currentVersion: {
|
||||
id: v.id,
|
||||
text: v.text,
|
||||
date: v.date,
|
||||
category: v.category,
|
||||
priority: v.priority,
|
||||
author: v.author,
|
||||
createdAt: v.createdAt,
|
||||
},
|
||||
execution: x
|
||||
? {
|
||||
id: x.id,
|
||||
author: x.author,
|
||||
createdAt: x.createdAt,
|
||||
text: x.text,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
55
src/lib/server/query/patient.ts
Normal file
55
src/lib/server/query/patient.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import type {
|
||||
Patient,
|
||||
PatientNew,
|
||||
PatientsRequest,
|
||||
Pagination,
|
||||
} from "$lib/shared/model";
|
||||
import { ZPatientNew } from "$lib/shared/model/validation";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { mapPatient } from "./mapping";
|
||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
import { convertFilterList } from "./util";
|
||||
|
||||
export async function newPatient(patient: PatientNew): Promise<number> {
|
||||
const data = ZPatientNew.parse(patient);
|
||||
const created = await prisma.patient.create({ data });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function getPatient(id: number): Promise<Patient> {
|
||||
const patient = await prisma.patient.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
room: { include: { station: true } },
|
||||
},
|
||||
});
|
||||
return mapPatient(patient);
|
||||
}
|
||||
|
||||
export async function getPatients(req: PatientsRequest): Promise<Pagination<Patient>> {
|
||||
const offset = req.pagination?.offset || 0;
|
||||
const where = {
|
||||
roomId: convertFilterList(req.filter?.room),
|
||||
room: {
|
||||
stationId: convertFilterList(req.filter?.station),
|
||||
},
|
||||
};
|
||||
const [patients, total] = await Promise.all([
|
||||
prisma.patient.findMany({
|
||||
where,
|
||||
include: {
|
||||
room: { include: { station: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: req.pagination?.limit || PAGINATION_LIMIT,
|
||||
}),
|
||||
prisma.patient.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: patients.map(mapPatient),
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
44
src/lib/server/query/room.ts
Normal file
44
src/lib/server/query/room.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type { RoomNew, Room, Station, StationNew } from "$lib/shared/model";
|
||||
import { ZRoomNew, ZStationNew } from "$lib/shared/model/validation";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { mapRoom } from "./mapping";
|
||||
|
||||
export async function newStation(station: StationNew): Promise<number> {
|
||||
const data = ZStationNew.parse(station);
|
||||
const created = await prisma.station.create({ data });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function getStation(id: number): Promise<Station> {
|
||||
return await prisma.station.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getStations(): Promise<Station[]> {
|
||||
return prisma.station.findMany({ orderBy: { id: "asc" } });
|
||||
}
|
||||
|
||||
export async function newRoom(room: RoomNew): Promise<number> {
|
||||
const data = ZRoomNew.parse(room);
|
||||
const created = await prisma.room.create({ data });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function getRoom(id: number): Promise<Room> {
|
||||
const room = await prisma.room.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { station: true },
|
||||
});
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
station: room.station,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRooms(): Promise<Room[]> {
|
||||
const rooms = await prisma.room.findMany({
|
||||
include: { station: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return rooms.map(mapRoom);
|
||||
}
|
26
src/lib/server/query/user.ts
Normal file
26
src/lib/server/query/user.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { Pagination, User, UserTag, UsersRequest } from "$lib/shared/model";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { mapUser, mapUserTag } from "./mapping";
|
||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
|
||||
export async function getUser(id: number): Promise<User> {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
export async function getUsers(req: UsersRequest): Promise<Pagination<UserTag>> {
|
||||
const offset = req.pagination?.offset || 0;
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { id: "asc" },
|
||||
skip: offset,
|
||||
take: req.pagination?.limit || PAGINATION_LIMIT,
|
||||
}),
|
||||
prisma.user.count(),
|
||||
]);
|
||||
return {
|
||||
items: users.map(mapUserTag),
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
20
src/lib/server/query/util.test.ts
Normal file
20
src/lib/server/query/util.test.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { QueryBuilder } from "./util";
|
||||
|
||||
test("query builder", () => {
|
||||
const qb = new QueryBuilder("select e.id, e.text, e.category", "from entries e");
|
||||
qb.addFilterList("category", [1, 2, 3]);
|
||||
qb.addFilter("text", "HelloWorld");
|
||||
qb.setPagination({ limit: 20, offset: 10 });
|
||||
|
||||
const query = qb.getQuery();
|
||||
expect(query).toBe(
|
||||
"select e.id, e.text, e.category from entries e where category in $1 and text = $2 limit $3 offset $4"
|
||||
);
|
||||
|
||||
const params = qb.getParams();
|
||||
expect(params[0]).toStrictEqual([1, 2, 3]);
|
||||
expect(params[1]).toBe("HelloWorld");
|
||||
expect(params[2]).toBe(20);
|
||||
expect(params[3]).toBe(10);
|
||||
});
|
100
src/lib/server/query/util.ts
Normal file
100
src/lib/server/query/util.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
import type { FilterList, PaginationRequest } from "$lib/shared/model";
|
||||
|
||||
export function convertFilterList<T>(
|
||||
fl: FilterList<T> | undefined
|
||||
): { in: T[] } | T | undefined {
|
||||
if (!fl) {
|
||||
return undefined;
|
||||
} else if (Array.isArray(fl)) {
|
||||
return { in: fl };
|
||||
} else {
|
||||
return fl;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryBuilder {
|
||||
private selectClause;
|
||||
private fromClause;
|
||||
private filterClauses: string[] = [];
|
||||
private orderClause = "";
|
||||
private params: unknown[] = [];
|
||||
private nP = 0;
|
||||
private limit = PAGINATION_LIMIT;
|
||||
private offset = 0;
|
||||
|
||||
constructor(selectClause: string, fromClause: string) {
|
||||
this.selectClause = selectClause;
|
||||
this.fromClause = fromClause;
|
||||
}
|
||||
|
||||
setPagination(pag: PaginationRequest) {
|
||||
this.limit = pag.limit;
|
||||
this.offset = pag.offset;
|
||||
}
|
||||
|
||||
setOrderClause(orderClause: string) {
|
||||
this.orderClause = orderClause;
|
||||
}
|
||||
|
||||
/** Get the next parameter variable (e.g. $1) and increment the counter */
|
||||
pvar(): string {
|
||||
this.nP += 1;
|
||||
return `$${this.nP}`;
|
||||
}
|
||||
|
||||
addFilter(fname: string, val: unknown | undefined) {
|
||||
if (val === undefined) return;
|
||||
|
||||
this.params.push(val);
|
||||
this.filterClauses.push(`${fname} = ${this.pvar()}`);
|
||||
}
|
||||
|
||||
addFilterClause(clause: string, ...params: unknown[]) {
|
||||
this.filterClauses.push(clause);
|
||||
this.params.push(...params);
|
||||
}
|
||||
|
||||
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
|
||||
if (fl === undefined) return;
|
||||
|
||||
this.params.push(fl);
|
||||
if (Array.isArray(fl)) {
|
||||
this.filterClauses.push(`${fname} in ${this.pvar()}`);
|
||||
} else {
|
||||
this.filterClauses.push(`${fname} = ${this.pvar()}`);
|
||||
}
|
||||
}
|
||||
|
||||
getQuery(): string {
|
||||
const queryParts = [this.selectClause, this.fromClause];
|
||||
if (this.filterClauses.length > 0) {
|
||||
queryParts.push("where " + this.filterClauses.join(" and "));
|
||||
}
|
||||
|
||||
if (this.orderClause.length > 0) queryParts.push(this.orderClause);
|
||||
queryParts.push(`limit $${this.nP + 1} offset $${this.nP + 2}`);
|
||||
|
||||
return queryParts.join(" ");
|
||||
}
|
||||
|
||||
getCountQuery(): string {
|
||||
const queryParts = ["select count(*) as count", this.fromClause];
|
||||
if (this.filterClauses.length > 0) {
|
||||
queryParts.push("where " + this.filterClauses.join(" and "));
|
||||
}
|
||||
return queryParts.join(" ");
|
||||
}
|
||||
|
||||
getParams(): unknown[] {
|
||||
return [...this.params, this.limit, this.offset];
|
||||
}
|
||||
|
||||
getCountParams(): unknown[] {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
getOffset(): number {
|
||||
return this.offset;
|
||||
}
|
||||
}
|
1
src/lib/shared/constants.ts
Normal file
1
src/lib/shared/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const PAGINATION_LIMIT = 20;
|
4
src/lib/shared/model/index.ts
Normal file
4
src/lib/shared/model/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type Option<T> = T | null;
|
||||
|
||||
export * from "./model";
|
||||
export * from "./requests";
|
108
src/lib/shared/model/model.ts
Normal file
108
src/lib/shared/model/model.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import type { Option } from ".";
|
||||
|
||||
export type Pagination<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
name: Option<string>;
|
||||
email: Option<string>;
|
||||
};
|
||||
|
||||
export type UserTag = {
|
||||
id: number;
|
||||
name: Option<string>;
|
||||
};
|
||||
|
||||
export type Station = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type StationNew = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Room = {
|
||||
id: number;
|
||||
name: string;
|
||||
station: Station;
|
||||
};
|
||||
|
||||
export type RoomNew = {
|
||||
name: string;
|
||||
stationId: number;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: Option<string>;
|
||||
description: Option<string>;
|
||||
};
|
||||
|
||||
export type CategoryNew = {
|
||||
name: string;
|
||||
color: Option<string>;
|
||||
description: Option<string>;
|
||||
};
|
||||
|
||||
export type Patient = {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
age: Option<number>;
|
||||
room: Option<Room>;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type PatientNew = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
age: Option<number>;
|
||||
roomId: number;
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
id: number;
|
||||
patient: Patient;
|
||||
createdAt: Date;
|
||||
currentVersion: EntryVersion;
|
||||
execution: Option<EntryExecution>;
|
||||
};
|
||||
|
||||
export type EntryNew = {
|
||||
patientId: number;
|
||||
version: EntryVersionNew;
|
||||
};
|
||||
|
||||
export type EntryVersion = {
|
||||
id: number;
|
||||
text: string;
|
||||
date: Date;
|
||||
category: Option<Category>;
|
||||
priority: boolean;
|
||||
author: UserTag;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type EntryVersionNew = {
|
||||
text: string;
|
||||
date: Date;
|
||||
categoryId: Option<number>;
|
||||
priority: boolean;
|
||||
};
|
||||
|
||||
export type EntryExecution = {
|
||||
id: number;
|
||||
author: UserTag;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type EntryExecutionNew = {
|
||||
text: string;
|
||||
};
|
34
src/lib/shared/model/requests.ts
Normal file
34
src/lib/shared/model/requests.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export type PaginationRequest = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type FilterList<T> = T | T[];
|
||||
|
||||
export type EntriesFilter = Partial<{
|
||||
done: boolean;
|
||||
author: FilterList<number>;
|
||||
executor: FilterList<number>;
|
||||
category: FilterList<number>;
|
||||
patient: FilterList<number>;
|
||||
station: FilterList<number>;
|
||||
room: FilterList<number>;
|
||||
priority: boolean;
|
||||
}>;
|
||||
|
||||
export type EntriesRequest = Partial<{
|
||||
filter: EntriesFilter;
|
||||
pagination: PaginationRequest;
|
||||
}>;
|
||||
|
||||
export type UsersRequest = Partial<{ pagination: PaginationRequest }>;
|
||||
|
||||
export type PatientsFilter = Partial<{
|
||||
station: FilterList<number>;
|
||||
room: FilterList<number>;
|
||||
}>;
|
||||
|
||||
export type PatientsRequest = Partial<{
|
||||
filter: PatientsFilter;
|
||||
pagination: PaginationRequest;
|
||||
}>;
|
55
src/lib/shared/model/validation.ts
Normal file
55
src/lib/shared/model/validation.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { z } from "zod";
|
||||
import { implement } from "$lib/shared/util/zod";
|
||||
import type {
|
||||
CategoryNew,
|
||||
EntryExecutionNew,
|
||||
EntryNew,
|
||||
EntryVersionNew,
|
||||
PatientNew,
|
||||
RoomNew,
|
||||
StationNew,
|
||||
} from ".";
|
||||
|
||||
const ZEntityId = z.number().int().nonnegative();
|
||||
const ZNameString = z.string().min(1).max(200).trim();
|
||||
const ZTextString = z.string().trim();
|
||||
|
||||
export const ZStationNew = implement<StationNew>().with({ name: ZNameString });
|
||||
|
||||
export const ZRoomNew = implement<RoomNew>().with({
|
||||
name: ZNameString,
|
||||
stationId: ZEntityId,
|
||||
});
|
||||
|
||||
export const ZCategoryNew = implement<CategoryNew>().with({
|
||||
name: ZNameString,
|
||||
description: ZTextString.nullable(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/[0-9A-Fa-f]{6}/)
|
||||
.toUpperCase()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const ZPatientNew = implement<PatientNew>().with({
|
||||
firstName: ZNameString,
|
||||
lastName: ZNameString,
|
||||
age: z.number().int().nonnegative().lt(200).nullable(),
|
||||
roomId: ZEntityId,
|
||||
});
|
||||
|
||||
export const ZEntryVersionNew = implement<EntryVersionNew>().with({
|
||||
text: ZTextString,
|
||||
date: z.date(),
|
||||
categoryId: ZEntityId.nullable(),
|
||||
priority: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZEntryNew = implement<EntryNew>().with({
|
||||
patientId: ZEntityId,
|
||||
version: ZEntryVersionNew,
|
||||
});
|
||||
|
||||
export const ZEntryExecutionNew = implement<EntryExecutionNew>().with({
|
||||
text: ZTextString,
|
||||
});
|
25
src/lib/shared/util/zod.ts
Normal file
25
src/lib/shared/util/zod.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// Source: https://github.com/colinhacks/zod/discussions/1928
|
||||
|
||||
type Implements<Model> = {
|
||||
[key in keyof Model]-?: undefined extends Model[key]
|
||||
? null extends Model[key]
|
||||
? z.ZodNullableType<z.ZodOptionalType<z.ZodType<Model[key]>>>
|
||||
: z.ZodOptionalType<z.ZodType<Model[key]>>
|
||||
: null extends Model[key]
|
||||
? z.ZodNullableType<z.ZodType<Model[key]>>
|
||||
: z.ZodType<Model[key]>;
|
||||
};
|
||||
|
||||
export function implement<Model = never>() {
|
||||
return {
|
||||
with: <
|
||||
Schema extends Implements<Model> & {
|
||||
[unknownKey in Exclude<keyof Schema, keyof Model>]: never;
|
||||
},
|
||||
>(
|
||||
schema: Schema
|
||||
) => z.object(schema),
|
||||
};
|
||||
}
|
|
@ -2,6 +2,9 @@
|
|||
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";
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
@ -10,15 +13,21 @@
|
|||
>
|
||||
<nav class="navbar w-full">
|
||||
<div class="flex flex-1">
|
||||
<NavLink route="/">Visitenbuch</NavLink>
|
||||
<NavLink route="/plan">Planung</NavLink>
|
||||
<NavLink route="/visit">Visite</NavLink>
|
||||
<NavLink route="/(app)" href="/"
|
||||
><Icon
|
||||
path={mdiHome}
|
||||
cls={$page.route.id === "/(app)" ? "text-primary" : ""}
|
||||
/></NavLink
|
||||
>
|
||||
<NavLink route="/(app)/plan" href="/plan">Planung</NavLink>
|
||||
<NavLink route="/(app)/visit" href="/visit">Visite</NavLink>
|
||||
</div>
|
||||
<div class="flex-0">
|
||||
{#if $page.data.session?.user}
|
||||
<div class="dropdown dropdown-hover">
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost">
|
||||
{$page.data.session.user?.name}
|
||||
<Icon path={mdiAccount} />
|
||||
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<ul
|
|
@ -1,7 +1,5 @@
|
|||
<script>
|
||||
import { page } from "$app/stores";
|
||||
|
||||
console.log("hello");
|
||||
</script>
|
||||
|
||||
<div class="prose">
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import "../app.pcss";
|
||||
import "../app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 text-base-content">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import adapter from "@sveltejs/adapter-auto";
|
||||
import { vitePreprocess } from "@sveltejs/kit/vite";
|
||||
import adapter from "@sveltejs/adapter-node";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
|
@ -8,10 +8,10 @@ const config = {
|
|||
preprocess: [vitePreprocess({})],
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
adapter: adapter({
|
||||
precompress: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
53
tests/helpers/reset-db.ts
Normal file
53
tests/helpers/reset-db.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { CATEGORIES } from "./testdata";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async () => {
|
||||
await prisma.$transaction([
|
||||
prisma.entryExecution.deleteMany(),
|
||||
prisma.entryVersion.deleteMany(),
|
||||
prisma.entry.deleteMany(),
|
||||
prisma.patient.deleteMany(),
|
||||
prisma.room.deleteMany(),
|
||||
prisma.station.deleteMany(),
|
||||
prisma.category.deleteMany(),
|
||||
prisma.user.deleteMany(),
|
||||
|
||||
prisma.user.createMany({
|
||||
data: [
|
||||
{ id: 1, name: "Sven Schulz", email: "sven.schulz@example.com" },
|
||||
{ id: 2, name: "Sabrina Loewe", email: "sabrina.loewe@example.com" },
|
||||
],
|
||||
}),
|
||||
prisma.category.createMany({ data: CATEGORIES }),
|
||||
prisma.station.createMany({
|
||||
data: [
|
||||
{ id: 1, name: "S1" },
|
||||
{ id: 2, name: "S2" },
|
||||
],
|
||||
}),
|
||||
prisma.room.createMany({
|
||||
data: [
|
||||
{ id: 1, name: "R1.1", stationId: 1 },
|
||||
{ id: 2, name: "R1.2", stationId: 1 },
|
||||
{ id: 3, name: "R2.1", stationId: 2 },
|
||||
],
|
||||
}),
|
||||
prisma.patient.createMany({
|
||||
data: [
|
||||
{ id: 1, firstName: "Andreas", lastName: "Bergmann", age: 22, roomId: 1 },
|
||||
{ id: 2, firstName: "Manuela", lastName: "Kortig", age: 41, roomId: 2 },
|
||||
{ id: 3, firstName: "Markus", lastName: "Schuster", age: 50, roomId: 3 },
|
||||
],
|
||||
}),
|
||||
prisma.$executeRaw`alter sequence users_id_seq restart with 3`,
|
||||
prisma.$executeRaw`alter sequence categories_id_seq restart with 7`,
|
||||
prisma.$executeRaw`alter sequence stations_id_seq restart with 3`,
|
||||
prisma.$executeRaw`alter sequence rooms_id_seq restart with 4`,
|
||||
prisma.$executeRaw`alter sequence patients_id_seq restart with 4`,
|
||||
prisma.$executeRaw`alter sequence entry_execution_id_seq restart with 1`,
|
||||
prisma.$executeRaw`alter sequence entry_versions_id_seq restart with 1`,
|
||||
prisma.$executeRaw`alter sequence entries_id_seq restart with 1`,
|
||||
]);
|
||||
};
|
7
tests/helpers/setup.ts
Normal file
7
tests/helpers/setup.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import resetDb from "./reset-db";
|
||||
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
beforeEach(async (x) => {
|
||||
await resetDb();
|
||||
});
|
43
tests/helpers/testdata.ts
Normal file
43
tests/helpers/testdata.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import type { Category, Station } from "$lib/shared/model";
|
||||
|
||||
export const S1: Station = { id: 1, name: "S1" };
|
||||
export const S2: Station = { id: 2, name: "S2" };
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Laborabnahme",
|
||||
description: "Blutabnahme zur Untersuchung im Labor",
|
||||
color: "FF0000",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Untersuchungen",
|
||||
description: "Durchführung von Untersuchungen",
|
||||
color: "00AA00",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Medikationsumstellung",
|
||||
description: null,
|
||||
color: "FF00FF",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Klinische Visite",
|
||||
description: null,
|
||||
color: "00FFFF",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Entlassung",
|
||||
description: "Entlassung eines Patienten aus dem KH",
|
||||
color: "55AAFF",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Sonstiges",
|
||||
description: null,
|
||||
color: "444444",
|
||||
},
|
||||
];
|
23
tests/integration/query/category.ts
Normal file
23
tests/integration/query/category.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { getCategories, getCategory, newCategory } from "$lib/server/query";
|
||||
import { expect, test } from "vitest";
|
||||
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
|
||||
import { CATEGORIES } from "../../helpers/testdata";
|
||||
|
||||
test("create category", async () => {
|
||||
const data = {
|
||||
name: "Test",
|
||||
description: "Hello World",
|
||||
color: "AABB01",
|
||||
};
|
||||
const cId = await newCategory(data);
|
||||
expect(cId).gt(0);
|
||||
|
||||
const category = await getCategory(cId);
|
||||
expect(category).toMatchObject(data);
|
||||
expect(category.id).toBe(cId);
|
||||
});
|
||||
|
||||
test("get categories", async () => {
|
||||
const categories = await getCategories();
|
||||
expect(categories).toStrictEqual(CATEGORIES);
|
||||
});
|
162
tests/integration/query/entry.ts
Normal file
162
tests/integration/query/entry.ts
Normal file
|
@ -0,0 +1,162 @@
|
|||
import {
|
||||
getEntries,
|
||||
getEntry,
|
||||
newEntry,
|
||||
newEntryExecution,
|
||||
newEntryVersion,
|
||||
} from "$lib/server/query";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const TEST_VERSION = {
|
||||
categoryId: 1,
|
||||
text: "10ml Blut abnehmen",
|
||||
date: new Date(2024, 1, 1),
|
||||
priority: false,
|
||||
};
|
||||
|
||||
test("create entry", async () => {
|
||||
const eId = await newEntry(1, {
|
||||
patientId: 1,
|
||||
version: TEST_VERSION,
|
||||
});
|
||||
expect(eId).gt(0);
|
||||
|
||||
const entry = await getEntry(eId);
|
||||
expect(entry.patient.id).toBe(1);
|
||||
expect(entry.execution).toBeNull();
|
||||
expect(entry.currentVersion.id).gt(0);
|
||||
expect(entry.currentVersion.category?.id).toBe(TEST_VERSION.categoryId);
|
||||
expect(entry.currentVersion.category?.name).toBe("Laborabnahme");
|
||||
expect(entry.currentVersion.text).toBe(TEST_VERSION.text);
|
||||
expect(entry.currentVersion.date).toStrictEqual(TEST_VERSION.date);
|
||||
expect(entry.currentVersion.priority).toBe(TEST_VERSION.priority);
|
||||
});
|
||||
|
||||
test("create extry version", async () => {
|
||||
const eId = await newEntry(1, {
|
||||
patientId: 1,
|
||||
version: TEST_VERSION,
|
||||
});
|
||||
const text = "10ml Blut abnehmen\n\nPS: Nadel nicht vergessen";
|
||||
|
||||
await newEntryVersion(1, eId, {
|
||||
...TEST_VERSION,
|
||||
text,
|
||||
});
|
||||
|
||||
const entry = await getEntry(eId);
|
||||
expect(entry.currentVersion.text).toBe(text);
|
||||
});
|
||||
|
||||
test("create extry execution", async () => {
|
||||
const eId = await newEntry(1, {
|
||||
patientId: 1,
|
||||
version: TEST_VERSION,
|
||||
});
|
||||
const text = "Blutabnahme erledigt.";
|
||||
|
||||
await newEntryExecution(1, eId, { text });
|
||||
const entry = await getEntry(eId);
|
||||
expect(entry.execution?.author.id).toBe(1);
|
||||
expect(entry.execution?.text).toBe(text);
|
||||
});
|
||||
|
||||
async function insertTestEntries() {
|
||||
// Create some entries
|
||||
const eId1 = await newEntry(1, {
|
||||
patientId: 1,
|
||||
version: TEST_VERSION,
|
||||
});
|
||||
const eId2 = await newEntry(1, {
|
||||
patientId: 2,
|
||||
version: {
|
||||
text: "Carrot cake jelly-o bonbon toffee chocolate.",
|
||||
date: new Date(2024, 1, 5),
|
||||
priority: false,
|
||||
categoryId: null,
|
||||
},
|
||||
});
|
||||
const eId3 = await newEntry(1, {
|
||||
patientId: 1,
|
||||
version: {
|
||||
text: "Cheesecake danish donut oat cake caramels.",
|
||||
date: new Date(2024, 1, 6),
|
||||
priority: false,
|
||||
categoryId: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update an entry
|
||||
await newEntryVersion(2, eId1, {
|
||||
categoryId: 3,
|
||||
text: "Hello World",
|
||||
date: new Date(2024, 1, 1),
|
||||
priority: true,
|
||||
});
|
||||
|
||||
// Execute entries
|
||||
await newEntryExecution(1, eId2, { text: "Some execution txt" });
|
||||
await newEntryExecution(2, eId3, { text: "More execution txt" });
|
||||
|
||||
return { eId1, eId2, eId3 };
|
||||
}
|
||||
|
||||
test("get entries", async () => {
|
||||
const { eId1, eId2, eId3 } = await insertTestEntries();
|
||||
const entries = await getEntries({});
|
||||
expect(entries.items).length(3);
|
||||
expect(entries.total).toBe(3);
|
||||
|
||||
// Pagination
|
||||
|
||||
const entriesLim2 = await getEntries({ pagination: { limit: 2, offset: 0 } });
|
||||
expect(entriesLim2.items).length(2);
|
||||
expect(entriesLim2.total).toBe(3);
|
||||
|
||||
const entriesLim2Offset = await getEntries({ pagination: { limit: 2, offset: 2 } });
|
||||
expect(entriesLim2Offset.items).length(1);
|
||||
expect(entriesLim2Offset.offset).toBe(2);
|
||||
expect(entriesLim2Offset.total).toBe(3);
|
||||
|
||||
// Filter by category
|
||||
const entriesCategory = await getEntries({ filter: { category: 3 } });
|
||||
expect(entriesCategory.items).length(1);
|
||||
expect(entriesCategory.total).toBe(1);
|
||||
expect(entriesCategory.items[0].id).toBe(eId1);
|
||||
|
||||
// Filter by author
|
||||
const entriesAuthor = await getEntries({ filter: { author: 2 } });
|
||||
expect(entriesAuthor.items).length(1);
|
||||
expect(entriesAuthor.total).toBe(1);
|
||||
expect(entriesAuthor.items[0].id).toBe(eId1);
|
||||
|
||||
// Filter by executor
|
||||
const entriesExecutor = await getEntries({ filter: { executor: 1 } });
|
||||
expect(entriesExecutor.items).length(1);
|
||||
expect(entriesExecutor.total).toBe(1);
|
||||
expect(entriesExecutor.items[0].id).toBe(eId2);
|
||||
|
||||
// Filter by patient
|
||||
const entriesPatient = await getEntries({ filter: { patient: 1 } });
|
||||
expect(entriesPatient.items).length(2);
|
||||
expect(entriesPatient.total).toBe(2);
|
||||
expect(entriesPatient.items[0].id).toBe(eId3);
|
||||
expect(entriesPatient.items[1].id).toBe(eId1);
|
||||
|
||||
// Filter by room
|
||||
const entriesRoom = await getEntries({ filter: { room: 1 } });
|
||||
expect(entriesRoom).toStrictEqual(entriesPatient);
|
||||
|
||||
// Filter done
|
||||
const entriesDone = await getEntries({ filter: { done: true } });
|
||||
expect(entriesDone.items).length(2);
|
||||
expect(entriesDone.total).toBe(2);
|
||||
expect(entriesDone.items[0].id).toBe(eId3);
|
||||
expect(entriesDone.items[1].id).toBe(eId2);
|
||||
|
||||
// Filter by priority
|
||||
const entriesPrio = await getEntries({ filter: { priority: true } });
|
||||
expect(entriesPrio.items).length(1);
|
||||
expect(entriesPrio.total).toBe(1);
|
||||
expect(entriesPrio.items[0].id).toBe(eId1);
|
||||
});
|
74
tests/integration/query/patient.ts
Normal file
74
tests/integration/query/patient.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { newPatient, getPatient, getPatients } from "$lib/server/query";
|
||||
import { expect, test } from "vitest";
|
||||
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
|
||||
import { S1, S2 } from "../../helpers/testdata";
|
||||
|
||||
test("create patient", async () => {
|
||||
const pId = await newPatient({
|
||||
firstName: "Max",
|
||||
lastName: "Müller",
|
||||
age: 31,
|
||||
roomId: 1,
|
||||
});
|
||||
const patient = await getPatient(pId);
|
||||
expect(patient).toMatchObject({
|
||||
id: pId,
|
||||
firstName: "Max",
|
||||
lastName: "Müller",
|
||||
age: 31,
|
||||
room: { id: 1, name: "R1.1", station: S1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("get patients", async () => {
|
||||
const patients = await getPatients({});
|
||||
expect(patients).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: "Andreas",
|
||||
lastName: "Bergmann",
|
||||
age: 22,
|
||||
room: { id: 1, name: "R1.1", station: S1 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
firstName: "Manuela",
|
||||
lastName: "Kortig",
|
||||
age: 41,
|
||||
room: { id: 2, name: "R1.2", station: S1 },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
firstName: "Markus",
|
||||
lastName: "Schuster",
|
||||
age: 50,
|
||||
room: { id: 3, name: "R2.1", station: S2 },
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("get patients (pagination)", async () => {
|
||||
const patients = await getPatients({ pagination: { offset: 1, limit: 100 } });
|
||||
expect(patients.items).length(2);
|
||||
expect(patients.items[0].id).toBe(2);
|
||||
expect(patients.items[1].id).toBe(3);
|
||||
expect(patients.offset).toBe(1);
|
||||
expect(patients.total).toBe(3);
|
||||
});
|
||||
|
||||
test("get patients (by room)", async () => {
|
||||
const patients = await getPatients({ filter: { room: 1 } });
|
||||
expect(patients.items).length(1);
|
||||
expect(patients.items[0].id).toBe(1);
|
||||
});
|
||||
|
||||
test("get patients (by station)", async () => {
|
||||
const patients = await getPatients({ filter: { station: 1 } });
|
||||
expect(patients.items).length(2);
|
||||
expect(patients.items[0].id).toBe(1);
|
||||
expect(patients.items[1].id).toBe(2);
|
||||
});
|
54
tests/integration/query/room.ts
Normal file
54
tests/integration/query/room.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
getRoom,
|
||||
getRooms,
|
||||
getStation,
|
||||
getStations,
|
||||
newRoom,
|
||||
newStation,
|
||||
} from "$lib/server/query";
|
||||
import type { Room, Station } from "$lib/shared/model";
|
||||
import { expect, test } from "vitest";
|
||||
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
|
||||
import { S1, S2 } from "../../helpers/testdata";
|
||||
|
||||
test("create station", async () => {
|
||||
const sId = await newStation({ name: "S3" });
|
||||
const station = await getStation(sId);
|
||||
expect(station).toStrictEqual({ id: sId, name: "S3" } satisfies Station);
|
||||
});
|
||||
|
||||
test("get stations", async () => {
|
||||
const stations = await getStations();
|
||||
expect(stations).toStrictEqual([S1, S2]);
|
||||
});
|
||||
|
||||
test("create room", async () => {
|
||||
const rId = await newRoom({ name: "A1", stationId: 1 });
|
||||
const room = await getRoom(rId);
|
||||
expect(room).toStrictEqual({
|
||||
id: rId,
|
||||
name: "A1",
|
||||
station: S1,
|
||||
} satisfies Room);
|
||||
});
|
||||
|
||||
test("get rooms", async () => {
|
||||
const rooms = await getRooms();
|
||||
expect(rooms).toStrictEqual([
|
||||
{
|
||||
id: 1,
|
||||
name: "R1.1",
|
||||
station: S1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "R1.2",
|
||||
station: S1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "R2.1",
|
||||
station: S2,
|
||||
},
|
||||
] satisfies Room[]);
|
||||
});
|
29
tests/integration/query/user.ts
Normal file
29
tests/integration/query/user.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { getUser, getUsers } from "$lib/server/query";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
test("get user", async () => {
|
||||
const user = await getUser(1);
|
||||
expect(user).toStrictEqual({
|
||||
id: 1,
|
||||
name: "Sven Schulz",
|
||||
email: "sven.schulz@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("get users", async () => {
|
||||
const users = await getUsers({});
|
||||
expect(users).toStrictEqual({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Sven Schulz",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Sabrina Loewe",
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
total: 2,
|
||||
});
|
||||
});
|
|
@ -1,8 +0,0 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("index page has expected h1", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Welcome to SvelteKit" })
|
||||
).toBeVisible();
|
||||
});
|
19
vitest.config.integration.ts
Normal file
19
vitest.config.integration.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
// Prisma integration tests with Vitest
|
||||
// https://www.prisma.io/blog/testing-series-3-aBUyF8nxAn
|
||||
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ["tests/integration/**/*.ts"],
|
||||
minWorkers: 1,
|
||||
maxWorkers: 1,
|
||||
maxConcurrency: 1,
|
||||
setupFiles: ["tests/helpers/setup.ts"],
|
||||
env: {
|
||||
DATABASE_URL: "postgresql://postgres:1234@localhost:5432/test?schema=public",
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Reference in a new issue