Compare commits
4 commits
34bed279db
...
0e72eb2a62
Author | SHA1 | Date | |
---|---|---|---|
0e72eb2a62 | |||
a3f7d4a8fd | |||
161d793ebf | |||
150a0eddd2 |
43 changed files with 853 additions and 170 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -8,3 +8,5 @@ node_modules
|
|||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
sveltekit-zero-api.d.ts
|
||||
/.vscode
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
"prisma": "^5.7.0",
|
||||
"svelte": "^4.2.8",
|
||||
"svelte-check": "^3.6.2",
|
||||
"sveltekit-zero-api": "^0.15.7",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"tslib": "^2.6.2",
|
||||
"tsx": "^4.7.0",
|
||||
|
|
|
@ -88,6 +88,9 @@ devDependencies:
|
|||
svelte-check:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2(postcss@8.4.32)(svelte@4.2.8)
|
||||
sveltekit-zero-api:
|
||||
specifier: ^0.15.7
|
||||
version: 0.15.7(@sveltejs/kit@2.0.4)(svelte@4.2.8)(typescript@5.3.3)
|
||||
tailwindcss:
|
||||
specifier: ^3.3.6
|
||||
version: 3.3.6
|
||||
|
@ -2771,6 +2774,18 @@ packages:
|
|||
magic-string: 0.30.5
|
||||
periscopic: 3.1.0
|
||||
|
||||
/sveltekit-zero-api@0.15.7(@sveltejs/kit@2.0.4)(svelte@4.2.8)(typescript@5.3.3):
|
||||
resolution: {integrity: sha512-yklGBeRfHw4dmMjvzskkuXbeakyKWyRqmiVbuD/a4xXPsU3TerBfNKAy6+7Bmpo2KIE2Q6LTdrs+JUQyYYtltA==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^1.5.5
|
||||
svelte: ^3.55.1
|
||||
typescript: '>=5.0.0'
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.0.4(@sveltejs/vite-plugin-svelte@3.0.1)(svelte@4.2.8)(vite@5.0.10)
|
||||
svelte: 4.2.8
|
||||
typescript: 5.3.3
|
||||
dev: true
|
||||
|
||||
/tailwindcss@3.3.6:
|
||||
resolution: {integrity: sha512-AKjF7qbbLvLaPieoKeTjG1+FyNZT6KaJMJPFeQyLfIp7l82ggH1fbHJSsYIvnbTFQOlkh+gBYpyby5GT1LIdLw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
|
@ -114,7 +114,7 @@ ALTER TABLE "rooms" ADD CONSTRAINT "rooms_station_id_fkey" FOREIGN KEY ("station
|
|||
ALTER TABLE "patients" ADD CONSTRAINT "patients_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rooms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "entries" ADD CONSTRAINT "entries_patient_id_fkey" FOREIGN KEY ("patient_id") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "entries" ADD CONSTRAINT "entries_patient_id_fkey" FOREIGN KEY ("patient_id") REFERENCES "patients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "entry_versions" ADD CONSTRAINT "entry_versions_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
CREATE EXTENSION pg_trgm;
|
||||
|
||||
ALTER TABLE entries
|
||||
ADD COLUMN tsvec tsvector;
|
||||
|
||||
|
@ -27,3 +29,8 @@ AFTER INSERT
|
|||
OR
|
||||
UPDATE ON entry_executions FOR EACH ROW
|
||||
EXECUTE PROCEDURE update_entry_tsvec ();
|
||||
|
||||
ALTER TABLE patients
|
||||
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
|
||||
|
||||
CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops);
|
||||
|
|
|
@ -93,7 +93,7 @@ model Category {
|
|||
|
||||
model Entry {
|
||||
id Int @id @default(autoincrement())
|
||||
patient Patient @relation(fields: [patient_id], references: [id], onDelete: Cascade)
|
||||
patient Patient @relation(fields: [patient_id], references: [id], onDelete: Restrict)
|
||||
patient_id Int
|
||||
|
||||
EntryVersion EntryVersion[]
|
||||
|
|
|
@ -9,4 +9,9 @@ echo 'Waiting for database to be 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
|
||||
cd "$DIR/../"
|
||||
DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force
|
||||
|
||||
# Reset main database
|
||||
npx prisma migrate reset --force
|
||||
npx tsx run/gen-mockdata.ts
|
||||
|
|
9
src/api.ts
Normal file
9
src/api.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createZeroApi } from "sveltekit-zero-api/api";
|
||||
import type { GeneratedAPI } from "./sveltekit-zero-api";
|
||||
|
||||
const routes = createZeroApi({
|
||||
// eslint-disable-next-line no-console
|
||||
onError: async (err) => console.error("[API]", err),
|
||||
}) as GeneratedAPI;
|
||||
|
||||
export default routes.api;
|
69
src/lib/server/handleError.ts
Normal file
69
src/lib/server/handleError.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { ErrorConflict, ErrorNotFound } from "$lib/shared/util/error";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
Ok,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
Conflict,
|
||||
InternalServerError,
|
||||
} from "sveltekit-zero-api/http";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { userId } from "./util";
|
||||
import type { RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
function handleError(error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
// return { status: 400, msg: "Invalid input", data: error.flatten() };
|
||||
return BadRequest({
|
||||
body: { status: 400, msg: "Invalid input", data: error.flatten() },
|
||||
});
|
||||
} else if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2025") {
|
||||
return NotFound({
|
||||
body: { status: 404, msg: error.message },
|
||||
});
|
||||
} else {
|
||||
return InternalServerError({
|
||||
body: { status: 500, msg: error.message, prismaCode: error.code },
|
||||
});
|
||||
}
|
||||
} else if (error instanceof ErrorNotFound) {
|
||||
return NotFound({
|
||||
body: { status: 404, msg: error.message },
|
||||
});
|
||||
} else if (error instanceof ErrorConflict) {
|
||||
return Conflict({
|
||||
body: { status: 409, msg: error.message },
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
return InternalServerError({
|
||||
body: { status: 500, msg: error.message },
|
||||
});
|
||||
} else {
|
||||
return InternalServerError({
|
||||
body: { status: 500, msg: "unknown error" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiWrap<T>(f: () => Promise<T>) {
|
||||
try {
|
||||
const body = await f();
|
||||
return Ok({ body });
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiWrapUser<T>(
|
||||
event: RequestEvent,
|
||||
f: (uId: number) => Promise<T>
|
||||
) {
|
||||
try {
|
||||
const uId = await userId(event);
|
||||
const body = await f(uId);
|
||||
return Ok({ body });
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
}
|
|
@ -1,13 +1,22 @@
|
|||
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, select: { id: true } });
|
||||
const created = await prisma.category.create({
|
||||
data: category,
|
||||
select: { id: true },
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function updateCategory(id: number, category: Partial<CategoryNew>) {
|
||||
await prisma.category.update({ where: { id }, data: category });
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: number) {
|
||||
await prisma.category.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getCategory(id: number): Promise<Category> {
|
||||
return prisma.category.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import { prisma } from "$lib/server/prisma";
|
||||
import type {
|
||||
EntriesRequest,
|
||||
EntriesFilter,
|
||||
Entry,
|
||||
EntryExecutionNew,
|
||||
EntryNew,
|
||||
EntryVersion,
|
||||
EntryVersionNew,
|
||||
Pagination,
|
||||
PaginationRequest,
|
||||
} from "$lib/shared/model";
|
||||
import {
|
||||
ZEntryExecutionNew,
|
||||
ZEntryNew,
|
||||
ZEntryVersionNew,
|
||||
} from "$lib/shared/model/validation";
|
||||
import { ErrorConflict } from "$lib/shared/util/error";
|
||||
import { mapEntry } from "./mapping";
|
||||
import { mapEntry, mapVersion } from "./mapping";
|
||||
import { QueryBuilder, parseSearchQuery } from "./util";
|
||||
|
||||
const USER_SELECT = { select: { id: true, name: true } };
|
||||
|
@ -39,15 +36,24 @@ export async function getEntry(id: number): Promise<Entry> {
|
|||
return mapEntry(entry);
|
||||
}
|
||||
|
||||
export async function getEntryHistory(id: number): Promise<EntryVersion[]> {
|
||||
const entries = await prisma.entryVersion.findMany({
|
||||
where: { entry_id: id },
|
||||
include: { author: USER_SELECT, category: true },
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
|
||||
return entries.map(mapVersion);
|
||||
}
|
||||
|
||||
export async function newEntry(author_id: number, entry: EntryNew): Promise<number> {
|
||||
const data = ZEntryNew.parse(entry);
|
||||
const created = await prisma.entry.create({
|
||||
data: {
|
||||
patient_id: data.patient_id,
|
||||
patient_id: entry.patient_id,
|
||||
EntryVersion: {
|
||||
create: {
|
||||
author_id,
|
||||
...data.version,
|
||||
...entry.version,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -61,8 +67,6 @@ export async function newEntryVersion(
|
|||
entry_id: number,
|
||||
version: EntryVersionNew
|
||||
): Promise<number> {
|
||||
const data = ZEntryVersionNew.parse(version);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const entry = await tx.entry.findUniqueOrThrow({
|
||||
where: { id: entry_id },
|
||||
|
@ -76,7 +80,7 @@ export async function newEntryVersion(
|
|||
const cver = entry.EntryVersion[0];
|
||||
|
||||
// Check if the entry has been updated by someone else
|
||||
if (data.old_version && (!cver || cver.id !== data.old_version)) {
|
||||
if (version.old_version && (!cver || cver.id !== version.old_version)) {
|
||||
throw new ErrorConflict("old version id does not match");
|
||||
}
|
||||
|
||||
|
@ -85,10 +89,10 @@ export async function newEntryVersion(
|
|||
// Old version
|
||||
entry_id,
|
||||
author_id,
|
||||
text: data.text || cver.text,
|
||||
date: data.date || cver.date,
|
||||
category_id: data.category_id || cver.category_id,
|
||||
priority: data.priority || cver.priority,
|
||||
text: version.text || cver.text,
|
||||
date: version.date || cver.date,
|
||||
category_id: version.category_id || cver.category_id,
|
||||
priority: version.priority || cver.priority,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
@ -101,8 +105,6 @@ export async function newEntryExecution(
|
|||
entry_id: number,
|
||||
execution: EntryExecutionNew
|
||||
): Promise<number> {
|
||||
const data = ZEntryExecutionNew.parse(execution);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const entry = await tx.entry.findUniqueOrThrow({
|
||||
where: { id: entry_id },
|
||||
|
@ -117,8 +119,8 @@ export async function newEntryExecution(
|
|||
|
||||
// Check if the execution has been updated by someone else
|
||||
if (
|
||||
(data.old_execution && (!cex || cex.id !== data.old_execution)) ||
|
||||
(data.old_execution === null && cex)
|
||||
(execution.old_execution && (!cex || cex.id !== execution.old_execution)) ||
|
||||
(execution.old_execution === null && cex)
|
||||
) {
|
||||
throw new ErrorConflict("old execution id does not match");
|
||||
}
|
||||
|
@ -127,7 +129,7 @@ export async function newEntryExecution(
|
|||
data: {
|
||||
entry_id,
|
||||
author_id,
|
||||
text: data.text,
|
||||
text: execution.text,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
@ -135,7 +137,10 @@ export async function newEntryExecution(
|
|||
});
|
||||
}
|
||||
|
||||
export async function getEntries(req: EntriesRequest): Promise<Pagination<Entry>> {
|
||||
export async function getEntries(
|
||||
filter: EntriesFilter,
|
||||
pagination: PaginationRequest
|
||||
): Promise<Pagination<Entry>> {
|
||||
const qb = new QueryBuilder(
|
||||
`select
|
||||
e.id,
|
||||
|
@ -197,29 +202,29 @@ join rooms r on r.id = p.room_id
|
|||
join stations s on s.id = r.station_id`
|
||||
);
|
||||
|
||||
if (req.filter?.search && req.filter.search.length > 0) {
|
||||
const query = parseSearchQuery(req.filter.search);
|
||||
if (filter?.search && filter.search.length > 0) {
|
||||
const query = parseSearchQuery(filter.search);
|
||||
qb.addFilterClause(
|
||||
`to_tsquery('german', ${qb.pvar()}) @@ e.tsvec`,
|
||||
query.toTsquery()
|
||||
);
|
||||
}
|
||||
|
||||
if (req.filter?.done === true) {
|
||||
if (filter?.done === true) {
|
||||
qb.addFilterClause("ex.id is not null");
|
||||
} else if (req.filter?.done === false) {
|
||||
} else if (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);
|
||||
qb.addFilterList("xau.id", filter?.executor);
|
||||
qb.addFilterList("c.id", filter?.category);
|
||||
qb.addFilterList("p.id", filter?.patient);
|
||||
qb.addFilterList("s.id", filter?.station);
|
||||
qb.addFilterList("r.id", filter?.room);
|
||||
qb.addFilter("ev.priority", filter?.priority);
|
||||
|
||||
if (req.filter?.author) {
|
||||
let author = req.filter?.author;
|
||||
if (filter?.author) {
|
||||
let author = filter?.author;
|
||||
if (!Array.isArray(author)) {
|
||||
author = [author];
|
||||
}
|
||||
|
@ -229,10 +234,10 @@ join stations s on s.id = r.station_id`
|
|||
);
|
||||
}
|
||||
|
||||
qb.setOrderClause(`order by e.created_at desc`);
|
||||
if (req.pagination) qb.setPagination(req.pagination);
|
||||
qb.addOrderClause("e.created_at desc");
|
||||
qb.setPagination(pagination);
|
||||
|
||||
type RequestItem = {
|
||||
type RowItem = {
|
||||
id: number;
|
||||
created_at: Date;
|
||||
text: string;
|
||||
|
@ -264,10 +269,9 @@ join stations s on s.id = r.station_id`
|
|||
const [res, countRes] = (await Promise.all([
|
||||
prisma.$queryRawUnsafe(qb.getQuery(), ...qb.getParams()),
|
||||
prisma.$queryRawUnsafe(qb.getCountQuery(), ...qb.getCountParams()),
|
||||
])) as [RequestItem[], { count: bigint }[]];
|
||||
])) as [RowItem[], { count: bigint }[]];
|
||||
|
||||
const total = Number(countRes[0].count);
|
||||
|
||||
const items: Entry[] = res.map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import type { Entry, Patient, User, UserTag, Room } from "$lib/shared/model";
|
||||
import type {
|
||||
Entry,
|
||||
Patient,
|
||||
User,
|
||||
UserTag,
|
||||
Room,
|
||||
EntryVersion,
|
||||
EntryExecution,
|
||||
} from "$lib/shared/model";
|
||||
import { ErrorNotFound } from "$lib/shared/util/error";
|
||||
import type {
|
||||
Patient as DbPatient,
|
||||
|
@ -18,6 +26,11 @@ type DbEntryVersionLn = DbEntryVersion & {
|
|||
author: UserTag;
|
||||
};
|
||||
type DbEntryExecutionLn = DbEntryExecution & { author: UserTag };
|
||||
type DbEntryFull = DbEntry & {
|
||||
EntryVersion: DbEntryVersionLn[];
|
||||
EntryExecution: DbEntryExecutionLn[];
|
||||
patient: DbPatientLn;
|
||||
};
|
||||
|
||||
export function mapPatient(patient: DbPatientLn): Patient {
|
||||
return {
|
||||
|
@ -48,13 +61,28 @@ 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 {
|
||||
export function mapVersion(version: DbEntryVersionLn): EntryVersion {
|
||||
return {
|
||||
id: version.id,
|
||||
text: version.text,
|
||||
date: version.date,
|
||||
category: version.category,
|
||||
priority: version.priority,
|
||||
author: version.author,
|
||||
created_at: version.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapExecution(execution: DbEntryExecutionLn): EntryExecution {
|
||||
return {
|
||||
id: execution.id,
|
||||
author: execution.author,
|
||||
created_at: execution.created_at,
|
||||
text: execution.text,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapEntry(entry: DbEntryFull): Entry {
|
||||
const v = entry.EntryVersion[0];
|
||||
if (!v) throw new ErrorNotFound("no version associated with that entry");
|
||||
const x = entry.EntryExecution[0];
|
||||
|
@ -63,22 +91,7 @@ export function mapEntry(
|
|||
id: entry.id,
|
||||
patient: mapPatient(entry.patient),
|
||||
created_at: entry.created_at,
|
||||
current_version: {
|
||||
id: v.id,
|
||||
text: v.text,
|
||||
date: v.date,
|
||||
category: v.category,
|
||||
priority: v.priority,
|
||||
author: v.author,
|
||||
created_at: v.created_at,
|
||||
},
|
||||
execution: x
|
||||
? {
|
||||
id: x.id,
|
||||
author: x.author,
|
||||
created_at: x.created_at,
|
||||
text: x.text,
|
||||
}
|
||||
: null,
|
||||
current_version: mapVersion(v),
|
||||
execution: x ? mapExecution(x) : null,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,21 +1,43 @@
|
|||
import type {
|
||||
Patient,
|
||||
PatientNew,
|
||||
PatientsRequest,
|
||||
Pagination,
|
||||
PaginationRequest,
|
||||
PatientsFilter,
|
||||
} from "$lib/shared/model";
|
||||
import { ZPatientNew } from "$lib/shared/model/validation";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { mapPatient } from "./mapping";
|
||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
import { convertFilterList } from "./util";
|
||||
import { QueryBuilder } from "./util";
|
||||
import { ErrorConflict } from "$lib/shared/util/error";
|
||||
|
||||
export async function newPatient(patient: PatientNew): Promise<number> {
|
||||
const data = ZPatientNew.parse(patient);
|
||||
const created = await prisma.patient.create({ data, select: { id: true } });
|
||||
const created = await prisma.patient.create({ data: patient, select: { id: true } });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Update a patient */
|
||||
export async function updatePatient(id: number, patient: Partial<PatientNew>) {
|
||||
await prisma.patient.update({ where: { id }, data: patient });
|
||||
}
|
||||
|
||||
/** Delete a patient (Note: this only works if the patient is not associated with any entries) */
|
||||
export async function deletePatient(id: number) {
|
||||
try {
|
||||
await prisma.patient.delete({ where: { id } });
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
// Foreign key constraint failed
|
||||
if (error.code === "P2003") {
|
||||
throw new ErrorConflict("cannot delete patient with entries");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Hide a patient
|
||||
|
||||
export async function getPatient(id: number): Promise<Patient> {
|
||||
const patient = await prisma.patient.findUniqueOrThrow({
|
||||
where: { id },
|
||||
|
@ -26,30 +48,68 @@ export async function getPatient(id: number): Promise<Patient> {
|
|||
return mapPatient(patient);
|
||||
}
|
||||
|
||||
export async function getPatients(req: PatientsRequest): Promise<Pagination<Patient>> {
|
||||
const offset = req.pagination?.offset || 0;
|
||||
const where = {
|
||||
room_id: convertFilterList(req.filter?.room),
|
||||
room: {
|
||||
station_id: convertFilterList(req.filter?.station),
|
||||
},
|
||||
export async function getPatients(
|
||||
filter: PatientsFilter,
|
||||
pagination: PaginationRequest
|
||||
): Promise<Pagination<Patient>> {
|
||||
const qb = new QueryBuilder(
|
||||
`select p.id, p.first_name, p.last_name, p.created_at, p.age,
|
||||
r.id as room_id, r.name as room_name, s.id as station_id, s.name as station_name`,
|
||||
`from patients p
|
||||
join rooms r on r.id = p.room_id
|
||||
join stations s on s.id = r.station_id`
|
||||
);
|
||||
|
||||
if (filter.search && filter.search.length > 0) {
|
||||
const qvar = qb.pvar();
|
||||
qb.addFilterClause(`p.full_name % ${qvar}`, filter.search);
|
||||
qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`);
|
||||
}
|
||||
|
||||
qb.addFilterList("r.id", filter.room);
|
||||
qb.addFilterList("s.id", filter.station);
|
||||
qb.addOrderClause("p.created_at desc");
|
||||
qb.setPagination(pagination);
|
||||
|
||||
type RowItem = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
created_at: Date;
|
||||
age: number;
|
||||
room_id: number;
|
||||
room_name: string;
|
||||
station_id: number;
|
||||
station_name: string;
|
||||
};
|
||||
const [patients, total] = await Promise.all([
|
||||
prisma.patient.findMany({
|
||||
where,
|
||||
include: {
|
||||
room: { include: { station: true } },
|
||||
|
||||
const [res, countRes] = (await Promise.all([
|
||||
prisma.$queryRawUnsafe(qb.getQuery(), ...qb.getParams()),
|
||||
prisma.$queryRawUnsafe(qb.getCountQuery(), ...qb.getCountParams()),
|
||||
])) as [RowItem[], { count: bigint }[]];
|
||||
|
||||
const total = Number(countRes[0].count);
|
||||
const items: Patient[] = res.map((patient) => {
|
||||
return {
|
||||
id: patient.id,
|
||||
first_name: patient.first_name,
|
||||
last_name: patient.last_name,
|
||||
age: patient.age,
|
||||
created_at: patient.created_at,
|
||||
room: {
|
||||
id: patient.room_id,
|
||||
name: patient.room_name,
|
||||
station: {
|
||||
id: patient.station_id,
|
||||
name: patient.station_name,
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
skip: offset,
|
||||
take: req.pagination?.limit || PAGINATION_LIMIT,
|
||||
}),
|
||||
prisma.patient.count({ where }),
|
||||
]);
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items: patients.map(mapPatient),
|
||||
offset,
|
||||
items,
|
||||
offset: qb.getOffset(),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
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, select: { id: true } });
|
||||
const created = await prisma.station.create({ data: station, select: { id: true } });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function updateStation(id: number, station: Partial<StationNew>) {
|
||||
await prisma.station.update({ where: { id }, data: station });
|
||||
}
|
||||
|
||||
export async function deleteStation(id: number) {
|
||||
await prisma.station.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getStation(id: number): Promise<Station> {
|
||||
return await prisma.station.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
@ -18,11 +24,18 @@ export async function getStations(): Promise<Station[]> {
|
|||
}
|
||||
|
||||
export async function newRoom(room: RoomNew): Promise<number> {
|
||||
const data = ZRoomNew.parse(room);
|
||||
const created = await prisma.room.create({ data, select: { id: true } });
|
||||
const created = await prisma.room.create({ data: room, select: { id: true } });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function updateRoom(id: number, room: Partial<RoomNew>) {
|
||||
await prisma.room.update({ where: { id }, data: room });
|
||||
}
|
||||
|
||||
export async function deleteRoom(id: number) {
|
||||
await prisma.room.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getRoom(id: number): Promise<Room> {
|
||||
const room = await prisma.room.findUniqueOrThrow({
|
||||
where: { id },
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Pagination, User, UserTag, UsersRequest } from "$lib/shared/model";
|
||||
import type { Pagination, PaginationRequest, User, UserTag } from "$lib/shared/model";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { mapUser, mapUserTag } from "./mapping";
|
||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
|
@ -8,13 +8,15 @@ export async function getUser(id: number): Promise<User> {
|
|||
return mapUser(user);
|
||||
}
|
||||
|
||||
export async function getUsers(req: UsersRequest): Promise<Pagination<UserTag>> {
|
||||
const offset = req.pagination?.offset || 0;
|
||||
export async function getUsers(
|
||||
pagination: PaginationRequest
|
||||
): Promise<Pagination<UserTag>> {
|
||||
const offset = pagination.offset || 0;
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { id: "asc" },
|
||||
skip: offset,
|
||||
take: req.pagination?.limit || PAGINATION_LIMIT,
|
||||
take: pagination.limit || PAGINATION_LIMIT,
|
||||
}),
|
||||
prisma.user.count(),
|
||||
]);
|
||||
|
|
|
@ -108,7 +108,7 @@ export class QueryBuilder {
|
|||
private selectClause;
|
||||
private fromClause;
|
||||
private filterClauses: string[] = [];
|
||||
private orderClause = "";
|
||||
private orderClauses: string[] = [];
|
||||
private params: unknown[] = [];
|
||||
private nP = 0;
|
||||
private limit = PAGINATION_LIMIT;
|
||||
|
@ -120,12 +120,12 @@ export class QueryBuilder {
|
|||
}
|
||||
|
||||
setPagination(pag: PaginationRequest) {
|
||||
this.limit = pag.limit;
|
||||
this.offset = pag.offset;
|
||||
if (pag.limit) this.limit = pag.limit;
|
||||
if (pag.offset) this.offset = pag.offset;
|
||||
}
|
||||
|
||||
setOrderClause(orderClause: string) {
|
||||
this.orderClause = orderClause;
|
||||
addOrderClause(orderClause: string) {
|
||||
this.orderClauses.push(orderClause);
|
||||
}
|
||||
|
||||
/** Get the next parameter variable (e.g. $1) and increment the counter */
|
||||
|
@ -163,7 +163,10 @@ export class QueryBuilder {
|
|||
queryParts.push("where " + this.filterClauses.join(" and "));
|
||||
}
|
||||
|
||||
if (this.orderClause.length > 0) queryParts.push(this.orderClause);
|
||||
if (this.orderClauses.length > 0) {
|
||||
queryParts.push(" order by ");
|
||||
queryParts.push(this.orderClauses.join(", "));
|
||||
}
|
||||
queryParts.push(`limit $${this.nP + 1} offset $${this.nP + 2}`);
|
||||
|
||||
return queryParts.join(" ");
|
||||
|
|
12
src/lib/server/util.ts
Normal file
12
src/lib/server/util.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
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");
|
||||
}
|
||||
}
|
|
@ -6,6 +6,19 @@ export type Pagination<T> = {
|
|||
offset: number;
|
||||
};
|
||||
|
||||
export type ApiBody<T> = {
|
||||
body: T;
|
||||
};
|
||||
|
||||
export type ApiError = {
|
||||
status: number;
|
||||
msg: string;
|
||||
} & Partial<{ data: unknown }>;
|
||||
|
||||
export type ApiCreated = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
name: Option<string>;
|
||||
|
@ -22,9 +35,7 @@ export type Station = {
|
|||
name: string;
|
||||
};
|
||||
|
||||
export type StationNew = {
|
||||
name: string;
|
||||
};
|
||||
export type StationNew = Omit<Station, "id">;
|
||||
|
||||
export type Room = {
|
||||
id: number;
|
||||
|
@ -44,11 +55,7 @@ export type Category = {
|
|||
description: Option<string>;
|
||||
};
|
||||
|
||||
export type CategoryNew = {
|
||||
name: string;
|
||||
color: Option<string>;
|
||||
description: Option<string>;
|
||||
};
|
||||
export type CategoryNew = Omit<Category, "id">;
|
||||
|
||||
export type Patient = {
|
||||
id: number;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export type PaginationRequest = {
|
||||
export type PaginationRequest = Partial<{
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type FilterList<T> = T | T[];
|
||||
|
||||
|
@ -17,19 +17,8 @@ export type EntriesFilter = Partial<{
|
|||
priority: boolean;
|
||||
}>;
|
||||
|
||||
export type EntriesRequest = Partial<{
|
||||
filter: EntriesFilter;
|
||||
pagination: PaginationRequest;
|
||||
}>;
|
||||
|
||||
export type UsersRequest = Partial<{ pagination: PaginationRequest }>;
|
||||
|
||||
export type PatientsFilter = Partial<{
|
||||
search: string;
|
||||
station: FilterList<number>;
|
||||
room: FilterList<number>;
|
||||
}>;
|
||||
|
||||
export type PatientsRequest = Partial<{
|
||||
filter: PatientsFilter;
|
||||
pagination: PaginationRequest;
|
||||
}>;
|
||||
|
|
25
src/lib/shared/model/validation.test.ts
Normal file
25
src/lib/shared/model/validation.test.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { ZEntriesFilterUrl, ZPatientsFilterUrl } from "./validation";
|
||||
import type { EntriesFilter, PatientsFilter } from ".";
|
||||
|
||||
test("valid EntriesFilterUrl", () => {
|
||||
const data = ZEntriesFilterUrl.parse({
|
||||
done: "0",
|
||||
});
|
||||
|
||||
expect(data).toStrictEqual({
|
||||
done: false,
|
||||
} satisfies EntriesFilter);
|
||||
});
|
||||
|
||||
test("valid PatientsFilterUrl", () => {
|
||||
const data = ZPatientsFilterUrl.parse({
|
||||
room: "1;2;3;4",
|
||||
station: "5",
|
||||
});
|
||||
|
||||
expect(data).toStrictEqual({
|
||||
room: [1, 2, 3, 4],
|
||||
station: [5],
|
||||
} satisfies PatientsFilter);
|
||||
});
|
|
@ -6,6 +6,7 @@ import type {
|
|||
EntryNew,
|
||||
EntryVersionNdata,
|
||||
EntryVersionNew,
|
||||
PaginationRequest,
|
||||
PatientNew,
|
||||
RoomNew,
|
||||
StationNew,
|
||||
|
@ -61,3 +62,38 @@ export const ZEntryExecutionNew = implement<EntryExecutionNew>().with({
|
|||
old_execution: ZEntityId.optional().nullable(),
|
||||
text: ZTextString,
|
||||
});
|
||||
|
||||
// From URL
|
||||
const ZFilterListUrl = z
|
||||
.string()
|
||||
.regex(/^\d+(;\d+)*$/)
|
||||
.transform((s) => s.split(";").map(Number))
|
||||
.optional();
|
||||
|
||||
export const ZEntityIdUrl = z.coerce.number().int().nonnegative();
|
||||
|
||||
export const BooleanUrl = z
|
||||
.string()
|
||||
.transform((s) => s !== "0" && s.toLowerCase() !== "false");
|
||||
|
||||
export const ZPaginationUrl = implement<PaginationRequest>().with({
|
||||
limit: ZEntityIdUrl.optional(),
|
||||
offset: ZEntityIdUrl.optional(),
|
||||
});
|
||||
|
||||
export const ZEntriesFilterUrl = z.object({
|
||||
author: ZFilterListUrl,
|
||||
category: ZFilterListUrl,
|
||||
done: BooleanUrl.optional(),
|
||||
executor: ZFilterListUrl,
|
||||
patient: ZFilterListUrl,
|
||||
priority: BooleanUrl.optional(),
|
||||
room: ZFilterListUrl,
|
||||
search: z.string().optional(),
|
||||
station: ZFilterListUrl,
|
||||
});
|
||||
|
||||
export const ZPatientsFilterUrl = z.object({
|
||||
room: ZFilterListUrl,
|
||||
station: ZFilterListUrl,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
<script lang="ts">
|
||||
import api from "$api";
|
||||
|
||||
api.category
|
||||
.id$(1)
|
||||
.GET()
|
||||
.Ok((resp) => console.log(resp.body));
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl">Planung</h1>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
|
||||
export const GET = () => {
|
||||
return json({ hello: "world" });
|
||||
};
|
21
src/routes/api/category/+server.ts
Normal file
21
src/routes/api/category/+server.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
|
||||
import { getCategories, newCategory } from "$lib/server/query";
|
||||
import type { ApiBody, CategoryNew } from "$lib/shared/model";
|
||||
import { ZCategoryNew } from "$lib/shared/model/validation";
|
||||
import { apiWrap } from "$lib/server/handleError";
|
||||
|
||||
/** Get all categories */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(getCategories);
|
||||
};
|
||||
|
||||
/** Create a new category */
|
||||
export const POST = async (event: KitEvent<ApiBody<CategoryNew>, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const category = ZCategoryNew.parse(await event.request.json());
|
||||
const id = await newCategory(category);
|
||||
return { id };
|
||||
});
|
||||
};
|
35
src/routes/api/category/[id]/+server.ts
Normal file
35
src/routes/api/category/[id]/+server.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { deleteCategory, getCategory, updateCategory } from "$lib/server/query";
|
||||
import { ZCategoryNew, ZEntityIdUrl } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { CategoryNew, ApiBody } from "$lib/shared/model";
|
||||
|
||||
/** Get a category */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
return await getCategory(id);
|
||||
});
|
||||
};
|
||||
|
||||
/** Update a category */
|
||||
export const PATCH = async (
|
||||
event: KitEvent<ApiBody<Partial<CategoryNew>>, RequestEvent>
|
||||
) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
const category = ZCategoryNew.partial().parse(await event.request.json());
|
||||
await updateCategory(id, category);
|
||||
return { id };
|
||||
});
|
||||
};
|
||||
|
||||
/** Delete a category */
|
||||
export const DELETE = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
await deleteCategory(id);
|
||||
return { id };
|
||||
});
|
||||
};
|
15
src/routes/api/entry/+server.ts
Normal file
15
src/routes/api/entry/+server.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { getEntries } from "$lib/server/query";
|
||||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { ZEntriesFilterUrl, ZPaginationUrl } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
|
||||
/** Get entries */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const params = Object.fromEntries(event.url.searchParams);
|
||||
const filter = ZEntriesFilterUrl.parse(params);
|
||||
const pagination = ZPaginationUrl.parse(params);
|
||||
return await getEntries(filter, pagination);
|
||||
});
|
||||
};
|
13
src/routes/api/entry/[id]/+server.ts
Normal file
13
src/routes/api/entry/[id]/+server.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { getEntry } from "$lib/server/query";
|
||||
import { ZEntityIdUrl } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
|
||||
/** Get a entry */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
return getEntry(id);
|
||||
});
|
||||
};
|
18
src/routes/api/entry/[id]/execution/+server.ts
Normal file
18
src/routes/api/entry/[id]/execution/+server.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, EntryExecutionNew } from "$lib/shared/model";
|
||||
import { apiWrapUser } from "$lib/server/handleError";
|
||||
import { ZEntityIdUrl, ZEntryExecutionNew } from "$lib/shared/model/validation";
|
||||
import { newEntryExecution } from "$lib/server/query";
|
||||
|
||||
/** Create an entry execution */
|
||||
export const POST = async (
|
||||
event: KitEvent<ApiBody<EntryExecutionNew>, RequestEvent>
|
||||
) => {
|
||||
return apiWrapUser(event, async (uId) => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
const execution = ZEntryExecutionNew.parse(await event.request.json());
|
||||
const newExecutionId = await newEntryExecution(uId, id, execution);
|
||||
return { id, newExecutionId };
|
||||
});
|
||||
};
|
26
src/routes/api/entry/[id]/version/+server.ts
Normal file
26
src/routes/api/entry/[id]/version/+server.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { apiWrap, apiWrapUser } from "$lib/server/handleError";
|
||||
import { getEntryHistory, newEntryVersion } from "$lib/server/query";
|
||||
import { ZEntityIdUrl, ZEntryVersionNew } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, EntryVersionNew } from "$lib/shared/model";
|
||||
|
||||
/** Get all versions of an entry */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
return getEntryHistory(id);
|
||||
});
|
||||
};
|
||||
|
||||
/** Create an entry version */
|
||||
export const PATCH = async (
|
||||
event: KitEvent<ApiBody<Partial<EntryVersionNew>>, RequestEvent>
|
||||
) => {
|
||||
return apiWrapUser(event, async (uId) => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
const version = ZEntryVersionNew.parse(await event.request.json());
|
||||
const newVersionId = await newEntryVersion(uId, id, version);
|
||||
return { id, newVersionId };
|
||||
});
|
||||
};
|
15
src/routes/api/patient/+server.ts
Normal file
15
src/routes/api/patient/+server.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { getPatients } from "$lib/server/query";
|
||||
import { ZPaginationUrl, ZPatientsFilterUrl } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
|
||||
/** Get patients */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const params = Object.fromEntries(event.url.searchParams);
|
||||
const filter = ZPatientsFilterUrl.parse(params);
|
||||
const pagination = ZPaginationUrl.parse(params);
|
||||
return await getPatients(filter, pagination);
|
||||
});
|
||||
};
|
35
src/routes/api/patient/[id]/+server.ts
Normal file
35
src/routes/api/patient/[id]/+server.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { deletePatient, getPatient, updatePatient } from "$lib/server/query";
|
||||
import { ZEntityIdUrl, ZPatientNew } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, PatientNew } from "$lib/shared/model";
|
||||
|
||||
/** Get a patient */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
return await getPatient(id);
|
||||
});
|
||||
};
|
||||
|
||||
/** Update a patient */
|
||||
export const PATCH = async (
|
||||
event: KitEvent<ApiBody<Partial<PatientNew>>, RequestEvent>
|
||||
) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
const patient = ZPatientNew.partial().parse(await event.request.json());
|
||||
await updatePatient(id, patient);
|
||||
return { id };
|
||||
});
|
||||
};
|
||||
|
||||
/** Delete a patient */
|
||||
export const DELETE = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
await deletePatient(id);
|
||||
return { id };
|
||||
});
|
||||
};
|
20
src/routes/api/room/+server.ts
Normal file
20
src/routes/api/room/+server.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { getRooms, newRoom } from "$lib/server/query";
|
||||
import { apiWrap } from "$lib/server/handleError";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, RoomNew } from "$lib/shared/model";
|
||||
import { ZRoomNew } from "$lib/shared/model/validation";
|
||||
|
||||
/** Get all rooms */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(getRooms);
|
||||
};
|
||||
|
||||
/** Create a new room */
|
||||
export const POST = async (event: KitEvent<ApiBody<RoomNew>, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const room = ZRoomNew.parse(await event.request.json());
|
||||
const id = await newRoom(room);
|
||||
return { id };
|
||||
});
|
||||
};
|
35
src/routes/api/room/[id]/+server.ts
Normal file
35
src/routes/api/room/[id]/+server.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { getRoom, updateRoom, deleteRoom } from "$lib/server/query";
|
||||
import { ZRoomNew, ZEntityIdUrl } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, RoomNew } from "$lib/shared/model";
|
||||
|
||||
/** Get a room */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
return await getRoom(id);
|
||||
});
|
||||
};
|
||||
|
||||
/** Update a room */
|
||||
export const PATCH = async (
|
||||
event: KitEvent<ApiBody<Partial<RoomNew>>, RequestEvent>
|
||||
) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
const room = ZRoomNew.partial().parse(await event.request.json());
|
||||
await updateRoom(id, room);
|
||||
return { id };
|
||||
});
|
||||
};
|
||||
|
||||
/** Delete a room */
|
||||
export const DELETE = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
await deleteRoom(id);
|
||||
return { id };
|
||||
});
|
||||
};
|
20
src/routes/api/station/+server.ts
Normal file
20
src/routes/api/station/+server.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { getRooms, newRoom } from "$lib/server/query";
|
||||
import { apiWrap } from "$lib/server/handleError";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, RoomNew } from "$lib/shared/model";
|
||||
import { ZRoomNew } from "$lib/shared/model/validation";
|
||||
|
||||
/** Get all rooms */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(getRooms);
|
||||
};
|
||||
|
||||
/** Create a new room */
|
||||
export const POST = async (event: KitEvent<ApiBody<RoomNew>, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const room = ZRoomNew.parse(await event.request.json());
|
||||
const id = await newRoom(room);
|
||||
return { id };
|
||||
});
|
||||
};
|
35
src/routes/api/station/[id]/+server.ts
Normal file
35
src/routes/api/station/[id]/+server.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { deleteStation, getStation, updateStation } from "$lib/server/query";
|
||||
import { ZEntityIdUrl, ZStationNew } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
import type { ApiBody, StationNew } from "$lib/shared/model";
|
||||
|
||||
/** Get a station */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
return await getStation(id);
|
||||
});
|
||||
};
|
||||
|
||||
/** Update a station */
|
||||
export const PATCH = async (
|
||||
event: KitEvent<ApiBody<Partial<StationNew>>, RequestEvent>
|
||||
) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
const station = ZStationNew.partial().parse(await event.request.json());
|
||||
await updateStation(id, station);
|
||||
return { id };
|
||||
});
|
||||
};
|
||||
|
||||
/** Delete a station */
|
||||
export const DELETE = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const id = ZEntityIdUrl.parse(event.params.id);
|
||||
await deleteStation(id);
|
||||
return { id };
|
||||
});
|
||||
};
|
14
src/routes/api/user/+server.ts
Normal file
14
src/routes/api/user/+server.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { apiWrap } from "$lib/server/handleError";
|
||||
import { getUsers } from "$lib/server/query";
|
||||
import { ZPaginationUrl } from "$lib/shared/model/validation";
|
||||
import type { KitEvent } from "sveltekit-zero-api";
|
||||
import type { RequestEvent } from "./$types";
|
||||
|
||||
/** Get users */
|
||||
export const GET = async (event: KitEvent<object, RequestEvent>) => {
|
||||
return apiWrap(async () => {
|
||||
const params = Object.fromEntries(event.url.searchParams);
|
||||
const pagination = ZPaginationUrl.parse(params);
|
||||
return await getUsers(pagination);
|
||||
});
|
||||
};
|
|
@ -12,6 +12,11 @@ const config = {
|
|||
adapter: adapter({
|
||||
precompress: true,
|
||||
}),
|
||||
|
||||
alias: {
|
||||
$api: "./src/api",
|
||||
$tests: "./tests",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -21,6 +21,10 @@ const N_USERS = 10;
|
|||
const N_ROOMS = 20;
|
||||
const N_PATIENTS = 50;
|
||||
|
||||
function randomId(len: number): number {
|
||||
return faker.number.int({ min: 1, max: len - 1 });
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
// Reset database
|
||||
await prisma.$transaction([
|
||||
|
@ -52,10 +56,6 @@ export default async () => {
|
|||
.split("\n")
|
||||
.map((l) => JSON.parse(l));
|
||||
|
||||
function randomId(len: number): number {
|
||||
return faker.number.int({ min: 1, max: len - 1 });
|
||||
}
|
||||
|
||||
for (let i = 1; i <= N_USERS; i++) {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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";
|
||||
import { CATEGORIES } from "$tests/helpers/testdata";
|
||||
|
||||
test("create category", async () => {
|
||||
const data = {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
getEntries,
|
||||
getEntry,
|
||||
getEntryHistory,
|
||||
newEntry,
|
||||
newEntryExecution,
|
||||
newEntryVersion,
|
||||
|
@ -49,13 +50,20 @@ test("create entry version", async () => {
|
|||
});
|
||||
|
||||
const entry = await getEntry(eId);
|
||||
expect(entry.current_version).toMatchObject({
|
||||
const expectedVersion = {
|
||||
author: { id: 1 },
|
||||
date,
|
||||
text,
|
||||
category: { id: 2 },
|
||||
priority: true,
|
||||
});
|
||||
};
|
||||
expect(entry.current_version).toMatchObject(expectedVersion);
|
||||
|
||||
const history = await getEntryHistory(eId);
|
||||
expect(history).length(2);
|
||||
expect(history[0]).toMatchObject(expectedVersion);
|
||||
expect(history[1].text).toBe(TEST_VERSION.text);
|
||||
expect(history[0].created_at).greaterThan(history[1].created_at);
|
||||
});
|
||||
|
||||
test("create entry version (partial)", async () => {
|
||||
|
@ -86,6 +94,14 @@ test("create entry version (wrong old vid)", async () => {
|
|||
patient_id: 1,
|
||||
version: TEST_VERSION,
|
||||
});
|
||||
const entry = await getEntry(eId);
|
||||
|
||||
expect(async () => {
|
||||
await newEntryVersion(1, eId, {
|
||||
old_version: entry.current_version.id + 1,
|
||||
text: "Hello World",
|
||||
});
|
||||
}).rejects.toThrowError(new ErrorConflict("old version id does not match"));
|
||||
});
|
||||
|
||||
test("create entry execution", async () => {
|
||||
|
@ -125,8 +141,8 @@ test("create entry execution (wrong old xid)", async () => {
|
|||
});
|
||||
const x1 = await newEntryExecution(1, eId, { old_execution: null, text: "x1" });
|
||||
|
||||
expect(async () =>
|
||||
newEntryExecution(1, eId, { old_execution: x1 + 1, text: "x2" })
|
||||
expect(
|
||||
async () => await newEntryExecution(1, eId, { old_execution: x1 + 1, text: "x2" })
|
||||
).rejects.toThrowError(new ErrorConflict("old execution id does not match"));
|
||||
});
|
||||
|
||||
|
@ -172,65 +188,65 @@ async function insertTestEntries() {
|
|||
|
||||
test("get entries", async () => {
|
||||
const { eId1, eId2, eId3 } = await insertTestEntries();
|
||||
const entries = await getEntries({});
|
||||
const entries = await getEntries({}, {});
|
||||
expect(entries.items).length(3);
|
||||
expect(entries.total).toBe(3);
|
||||
|
||||
// Pagination
|
||||
|
||||
const entriesLim2 = await getEntries({ pagination: { limit: 2, offset: 0 } });
|
||||
const entriesLim2 = await getEntries({}, { limit: 2, offset: 0 });
|
||||
expect(entriesLim2.items).length(2);
|
||||
expect(entriesLim2.total).toBe(3);
|
||||
|
||||
const entriesLim2Offset = await getEntries({ pagination: { limit: 2, offset: 2 } });
|
||||
const entriesLim2Offset = await getEntries({}, { 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 } });
|
||||
const entriesCategory = await getEntries({ 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 } });
|
||||
const entriesAuthor = await getEntries({ 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 } });
|
||||
const entriesExecutor = await getEntries({ 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 } });
|
||||
const entriesPatient = await getEntries({ 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 } });
|
||||
const entriesRoom = await getEntries({ room: 1 }, {});
|
||||
expect(entriesRoom).toStrictEqual(entriesPatient);
|
||||
|
||||
// Filter done
|
||||
const entriesDone = await getEntries({ filter: { done: true } });
|
||||
const entriesDone = await getEntries({ 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 } });
|
||||
const entriesPrio = await getEntries({ priority: true }, {});
|
||||
expect(entriesPrio.items).length(1);
|
||||
expect(entriesPrio.total).toBe(1);
|
||||
expect(entriesPrio.items[0].id).toBe(eId1);
|
||||
|
||||
// Search
|
||||
const entriesSearch = await getEntries({ filter: { search: "Blu" } });
|
||||
const entriesSearch = await getEntries({ search: "Blu" }, {});
|
||||
expect(entriesSearch.items).length(1);
|
||||
expect(entriesSearch.total).toBe(1);
|
||||
expect(entriesSearch.items[0].id).toBe(eId1);
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { newPatient, getPatient, getPatients } from "$lib/server/query";
|
||||
import {
|
||||
newPatient,
|
||||
getPatient,
|
||||
getPatients,
|
||||
updatePatient,
|
||||
deletePatient,
|
||||
newEntry,
|
||||
} 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";
|
||||
import { S1, S2 } from "$tests/helpers/testdata";
|
||||
|
||||
test("create patient", async () => {
|
||||
const pId = await newPatient({
|
||||
|
@ -20,8 +26,47 @@ test("create patient", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("update patient", async () => {
|
||||
const data = {
|
||||
first_name: "Max",
|
||||
last_name: "Mustermann",
|
||||
age: 66,
|
||||
room_id: 2,
|
||||
};
|
||||
await updatePatient(1, data);
|
||||
const patient = await getPatient(1);
|
||||
expect(patient).toMatchObject({
|
||||
first_name: "Max",
|
||||
last_name: "Mustermann",
|
||||
age: 66,
|
||||
room: { id: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
test("delete patient", async () => {
|
||||
await deletePatient(1);
|
||||
expect(async () => await getPatient(1)).rejects.toThrowError("No Patient found");
|
||||
});
|
||||
|
||||
test("delete patient (restricted)", async () => {
|
||||
// Patients should not be deleted if they have at least 1 entry
|
||||
const pId = await newEntry(1, {
|
||||
patient_id: 1,
|
||||
version: {
|
||||
category_id: null,
|
||||
date: new Date(2024, 1, 1),
|
||||
priority: false,
|
||||
text: "Hello World",
|
||||
},
|
||||
});
|
||||
|
||||
expect(async () => await deletePatient(pId)).rejects.toThrowError(
|
||||
"cannot delete patient with entries"
|
||||
);
|
||||
});
|
||||
|
||||
test("get patients", async () => {
|
||||
const patients = await getPatients({});
|
||||
const patients = await getPatients({}, {});
|
||||
expect(patients).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
|
@ -52,7 +97,7 @@ test("get patients", async () => {
|
|||
});
|
||||
|
||||
test("get patients (pagination)", async () => {
|
||||
const patients = await getPatients({ pagination: { offset: 1, limit: 100 } });
|
||||
const patients = await getPatients({}, { offset: 1, limit: 100 });
|
||||
expect(patients.items).length(2);
|
||||
expect(patients.items[0].id).toBe(2);
|
||||
expect(patients.items[1].id).toBe(3);
|
||||
|
@ -61,14 +106,20 @@ test("get patients (pagination)", async () => {
|
|||
});
|
||||
|
||||
test("get patients (by room)", async () => {
|
||||
const patients = await getPatients({ filter: { room: 1 } });
|
||||
const patients = await getPatients({ 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 } });
|
||||
const patients = await getPatients({ station: 1 }, {});
|
||||
expect(patients.items).length(2);
|
||||
expect(patients.items[0].id).toBe(1);
|
||||
expect(patients.items[1].id).toBe(2);
|
||||
});
|
||||
|
||||
test("search patients", async () => {
|
||||
const patients = await getPatients({ search: "Schustr" }, {});
|
||||
expect(patients.items).length(1);
|
||||
expect(patients.items[0].id).toBe(3);
|
||||
});
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import {
|
||||
deleteStation,
|
||||
getRoom,
|
||||
getRooms,
|
||||
getStation,
|
||||
getStations,
|
||||
newRoom,
|
||||
newStation,
|
||||
updateStation,
|
||||
} 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";
|
||||
import { S1, S2 } from "$tests/helpers/testdata";
|
||||
|
||||
test("create station", async () => {
|
||||
const sId = await newStation({ name: "S3" });
|
||||
|
@ -17,6 +18,19 @@ test("create station", async () => {
|
|||
expect(station).toStrictEqual({ id: sId, name: "S3" } satisfies Station);
|
||||
});
|
||||
|
||||
test("update station", async () => {
|
||||
const name = "HelloStation";
|
||||
await updateStation(S1.id, { name });
|
||||
const station = await getStation(S1.id);
|
||||
expect(station.id).toBe(S1.id);
|
||||
expect(station.name).toBe(name);
|
||||
});
|
||||
|
||||
test("delete station", async () => {
|
||||
await deleteStation(S1.id);
|
||||
expect(async () => await getStation(S1.id)).rejects.toThrowError("No Station found");
|
||||
});
|
||||
|
||||
test("get stations", async () => {
|
||||
const stations = await getStations();
|
||||
expect(stations).toStrictEqual([S1, S2]);
|
||||
|
@ -32,6 +46,19 @@ test("create room", async () => {
|
|||
} satisfies Room);
|
||||
});
|
||||
|
||||
test("update room", async () => {
|
||||
const name = "HelloStation";
|
||||
await updateStation(S1.id, { name });
|
||||
const station = await getStation(S1.id);
|
||||
expect(station.id).toBe(S1.id);
|
||||
expect(station.name).toBe(name);
|
||||
});
|
||||
|
||||
test("delete room", async () => {
|
||||
await deleteStation(S1.id);
|
||||
expect(async () => await getStation(S1.id)).rejects.toThrowError("No Station found");
|
||||
});
|
||||
|
||||
test("get rooms", async () => {
|
||||
const rooms = await getRooms();
|
||||
expect(rooms).toStrictEqual([
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { zeroAPI } from "sveltekit-zero-api";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
plugins: [sveltekit(), zeroAPI()],
|
||||
test: {
|
||||
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue