diff --git a/.gitignore b/.gitignore index 6635cf5..bb5445f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ node_modules !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* +sveltekit-zero-api.d.ts +/.vscode diff --git a/package.json b/package.json index c6f2d5f..1626865 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fecf993..2d4fa53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/prisma/migrations/20240113185049_init/migration.sql b/prisma/migrations/20240113185049_init/migration.sql index bc77201..ff8c396 100644 --- a/prisma/migrations/20240113185049_init/migration.sql +++ b/prisma/migrations/20240113185049_init/migration.sql @@ -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; diff --git a/prisma/migrations/20240113221445_search_index/migration.sql b/prisma/migrations/20240113221445_search_index/migration.sql index 451f20e..cd6b5db 100644 --- a/prisma/migrations/20240113221445_search_index/migration.sql +++ b/prisma/migrations/20240113221445_search_index/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a666eb..55bae29 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/run/db_up.sh b/run/db_up.sh index 82ca3b6..ab308f0 100755 --- a/run/db_up.sh +++ b/run/db_up.sh @@ -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 diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..ccf91ce --- /dev/null +++ b/src/api.ts @@ -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; diff --git a/src/lib/server/handleError.ts b/src/lib/server/handleError.ts new file mode 100644 index 0000000..2e2bc69 --- /dev/null +++ b/src/lib/server/handleError.ts @@ -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(f: () => Promise) { + try { + const body = await f(); + return Ok({ body }); + } catch (error) { + return handleError(error); + } +} + +export async function apiWrapUser( + event: RequestEvent, + f: (uId: number) => Promise +) { + try { + const uId = await userId(event); + const body = await f(uId); + return Ok({ body }); + } catch (error) { + return handleError(error); + } +} diff --git a/src/lib/server/query/category.ts b/src/lib/server/query/category.ts index fe5a6f7..11ac95f 100644 --- a/src/lib/server/query/category.ts +++ b/src/lib/server/query/category.ts @@ -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 { - 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) { + 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 { return prisma.category.findUniqueOrThrow({ where: { id } }); } diff --git a/src/lib/server/query/entry.ts b/src/lib/server/query/entry.ts index 2f6659c..7b8c5e7 100644 --- a/src/lib/server/query/entry.ts +++ b/src/lib/server/query/entry.ts @@ -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 { return mapEntry(entry); } +export async function getEntryHistory(id: number): Promise { + 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 { - 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 { - 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 { - 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> { +export async function getEntries( + filter: EntriesFilter, + pagination: PaginationRequest +): Promise> { 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, diff --git a/src/lib/server/query/mapping.ts b/src/lib/server/query/mapping.ts index cd560e0..86ac865 100644 --- a/src/lib/server/query/mapping.ts +++ b/src/lib/server/query/mapping.ts @@ -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, }; } diff --git a/src/lib/server/query/patient.ts b/src/lib/server/query/patient.ts index 44c4e53..55bcef2 100644 --- a/src/lib/server/query/patient.ts +++ b/src/lib/server/query/patient.ts @@ -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 { - 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) { + 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 { const patient = await prisma.patient.findUniqueOrThrow({ where: { id }, @@ -26,30 +48,68 @@ export async function getPatient(id: number): Promise { return mapPatient(patient); } -export async function getPatients(req: PatientsRequest): Promise> { - 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> { + 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, }; } diff --git a/src/lib/server/query/room.ts b/src/lib/server/query/room.ts index b3b62ff..0a2d5ab 100644 --- a/src/lib/server/query/room.ts +++ b/src/lib/server/query/room.ts @@ -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 { - 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) { + 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 { return await prisma.station.findUniqueOrThrow({ where: { id } }); } @@ -18,11 +24,18 @@ export async function getStations(): Promise { } export async function newRoom(room: RoomNew): Promise { - 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) { + 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 { const room = await prisma.room.findUniqueOrThrow({ where: { id }, diff --git a/src/lib/server/query/user.ts b/src/lib/server/query/user.ts index 600ef07..da7ce65 100644 --- a/src/lib/server/query/user.ts +++ b/src/lib/server/query/user.ts @@ -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 { return mapUser(user); } -export async function getUsers(req: UsersRequest): Promise> { - const offset = req.pagination?.offset || 0; +export async function getUsers( + pagination: PaginationRequest +): Promise> { + 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(), ]); diff --git a/src/lib/server/query/util.ts b/src/lib/server/query/util.ts index 0ace9d7..4c841fb 100644 --- a/src/lib/server/query/util.ts +++ b/src/lib/server/query/util.ts @@ -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(" "); diff --git a/src/lib/server/util.ts b/src/lib/server/util.ts new file mode 100644 index 0000000..591dfb3 --- /dev/null +++ b/src/lib/server/util.ts @@ -0,0 +1,12 @@ +import type { RequestEvent } from "@sveltejs/kit"; + +export async function userId(event: RequestEvent): Promise { + 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"); + } +} diff --git a/src/lib/shared/model/model.ts b/src/lib/shared/model/model.ts index ebc9d6b..3efdfd3 100644 --- a/src/lib/shared/model/model.ts +++ b/src/lib/shared/model/model.ts @@ -6,6 +6,19 @@ export type Pagination = { offset: number; }; +export type ApiBody = { + body: T; +}; + +export type ApiError = { + status: number; + msg: string; +} & Partial<{ data: unknown }>; + +export type ApiCreated = { + id: number; +}; + export type User = { id: number; name: Option; @@ -22,9 +35,7 @@ export type Station = { name: string; }; -export type StationNew = { - name: string; -}; +export type StationNew = Omit; export type Room = { id: number; @@ -44,11 +55,7 @@ export type Category = { description: Option; }; -export type CategoryNew = { - name: string; - color: Option; - description: Option; -}; +export type CategoryNew = Omit; export type Patient = { id: number; diff --git a/src/lib/shared/model/requests.ts b/src/lib/shared/model/requests.ts index 823ee28..aa1f93f 100644 --- a/src/lib/shared/model/requests.ts +++ b/src/lib/shared/model/requests.ts @@ -1,7 +1,7 @@ -export type PaginationRequest = { +export type PaginationRequest = Partial<{ limit: number; offset: number; -}; +}>; export type FilterList = 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; room: FilterList; }>; - -export type PatientsRequest = Partial<{ - filter: PatientsFilter; - pagination: PaginationRequest; -}>; diff --git a/src/lib/shared/model/validation.test.ts b/src/lib/shared/model/validation.test.ts new file mode 100644 index 0000000..22d9049 --- /dev/null +++ b/src/lib/shared/model/validation.test.ts @@ -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); +}); diff --git a/src/lib/shared/model/validation.ts b/src/lib/shared/model/validation.ts index 0901551..f3ec458 100644 --- a/src/lib/shared/model/validation.ts +++ b/src/lib/shared/model/validation.ts @@ -6,6 +6,7 @@ import type { EntryNew, EntryVersionNdata, EntryVersionNew, + PaginationRequest, PatientNew, RoomNew, StationNew, @@ -61,3 +62,38 @@ export const ZEntryExecutionNew = implement().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().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, +}); diff --git a/src/routes/(app)/plan/+page.svelte b/src/routes/(app)/plan/+page.svelte index 43c892d..bab84fa 100644 --- a/src/routes/(app)/plan/+page.svelte +++ b/src/routes/(app)/plan/+page.svelte @@ -1,4 +1,10 @@

Planung

diff --git a/src/routes/api/+server.ts b/src/routes/api/+server.ts deleted file mode 100644 index c0fd26a..0000000 --- a/src/routes/api/+server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { json } from "@sveltejs/kit"; - -export const GET = () => { - return json({ hello: "world" }); -}; diff --git a/src/routes/api/category/+server.ts b/src/routes/api/category/+server.ts new file mode 100644 index 0000000..4fa7f10 --- /dev/null +++ b/src/routes/api/category/+server.ts @@ -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) => { + return apiWrap(getCategories); +}; + +/** Create a new category */ +export const POST = async (event: KitEvent, RequestEvent>) => { + return apiWrap(async () => { + const category = ZCategoryNew.parse(await event.request.json()); + const id = await newCategory(category); + return { id }; + }); +}; diff --git a/src/routes/api/category/[id]/+server.ts b/src/routes/api/category/[id]/+server.ts new file mode 100644 index 0000000..7bb5c74 --- /dev/null +++ b/src/routes/api/category/[id]/+server.ts @@ -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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + return await getCategory(id); + }); +}; + +/** Update a category */ +export const PATCH = async ( + event: KitEvent>, 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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + await deleteCategory(id); + return { id }; + }); +}; diff --git a/src/routes/api/entry/+server.ts b/src/routes/api/entry/+server.ts new file mode 100644 index 0000000..d37241b --- /dev/null +++ b/src/routes/api/entry/+server.ts @@ -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) => { + 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); + }); +}; diff --git a/src/routes/api/entry/[id]/+server.ts b/src/routes/api/entry/[id]/+server.ts new file mode 100644 index 0000000..65933e7 --- /dev/null +++ b/src/routes/api/entry/[id]/+server.ts @@ -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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + return getEntry(id); + }); +}; diff --git a/src/routes/api/entry/[id]/execution/+server.ts b/src/routes/api/entry/[id]/execution/+server.ts new file mode 100644 index 0000000..a8cb8ea --- /dev/null +++ b/src/routes/api/entry/[id]/execution/+server.ts @@ -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, 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 }; + }); +}; diff --git a/src/routes/api/entry/[id]/version/+server.ts b/src/routes/api/entry/[id]/version/+server.ts new file mode 100644 index 0000000..2ccbc5b --- /dev/null +++ b/src/routes/api/entry/[id]/version/+server.ts @@ -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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + return getEntryHistory(id); + }); +}; + +/** Create an entry version */ +export const PATCH = async ( + event: KitEvent>, 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 }; + }); +}; diff --git a/src/routes/api/patient/+server.ts b/src/routes/api/patient/+server.ts new file mode 100644 index 0000000..0333422 --- /dev/null +++ b/src/routes/api/patient/+server.ts @@ -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) => { + 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); + }); +}; diff --git a/src/routes/api/patient/[id]/+server.ts b/src/routes/api/patient/[id]/+server.ts new file mode 100644 index 0000000..ab2ff97 --- /dev/null +++ b/src/routes/api/patient/[id]/+server.ts @@ -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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + return await getPatient(id); + }); +}; + +/** Update a patient */ +export const PATCH = async ( + event: KitEvent>, 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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + await deletePatient(id); + return { id }; + }); +}; diff --git a/src/routes/api/room/+server.ts b/src/routes/api/room/+server.ts new file mode 100644 index 0000000..4d0ff40 --- /dev/null +++ b/src/routes/api/room/+server.ts @@ -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) => { + return apiWrap(getRooms); +}; + +/** Create a new room */ +export const POST = async (event: KitEvent, RequestEvent>) => { + return apiWrap(async () => { + const room = ZRoomNew.parse(await event.request.json()); + const id = await newRoom(room); + return { id }; + }); +}; diff --git a/src/routes/api/room/[id]/+server.ts b/src/routes/api/room/[id]/+server.ts new file mode 100644 index 0000000..645b4b2 --- /dev/null +++ b/src/routes/api/room/[id]/+server.ts @@ -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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + return await getRoom(id); + }); +}; + +/** Update a room */ +export const PATCH = async ( + event: KitEvent>, 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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + await deleteRoom(id); + return { id }; + }); +}; diff --git a/src/routes/api/station/+server.ts b/src/routes/api/station/+server.ts new file mode 100644 index 0000000..4d0ff40 --- /dev/null +++ b/src/routes/api/station/+server.ts @@ -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) => { + return apiWrap(getRooms); +}; + +/** Create a new room */ +export const POST = async (event: KitEvent, RequestEvent>) => { + return apiWrap(async () => { + const room = ZRoomNew.parse(await event.request.json()); + const id = await newRoom(room); + return { id }; + }); +}; diff --git a/src/routes/api/station/[id]/+server.ts b/src/routes/api/station/[id]/+server.ts new file mode 100644 index 0000000..639dad3 --- /dev/null +++ b/src/routes/api/station/[id]/+server.ts @@ -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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + return await getStation(id); + }); +}; + +/** Update a station */ +export const PATCH = async ( + event: KitEvent>, 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) => { + return apiWrap(async () => { + const id = ZEntityIdUrl.parse(event.params.id); + await deleteStation(id); + return { id }; + }); +}; diff --git a/src/routes/api/user/+server.ts b/src/routes/api/user/+server.ts new file mode 100644 index 0000000..2cfc29f --- /dev/null +++ b/src/routes/api/user/+server.ts @@ -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) => { + return apiWrap(async () => { + const params = Object.fromEntries(event.url.searchParams); + const pagination = ZPaginationUrl.parse(params); + return await getUsers(pagination); + }); +}; diff --git a/svelte.config.js b/svelte.config.js index ee2ae49..246bba2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -12,6 +12,11 @@ const config = { adapter: adapter({ precompress: true, }), + + alias: { + $api: "./src/api", + $tests: "./tests", + }, }, }; diff --git a/tests/helpers/generate-mockdata.ts b/tests/helpers/generate-mockdata.ts index 1cfb031..53b7356 100644 --- a/tests/helpers/generate-mockdata.ts +++ b/tests/helpers/generate-mockdata.ts @@ -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(); diff --git a/tests/integration/query/category.ts b/tests/integration/query/category.ts index 6ae8b60..3e43e70 100644 --- a/tests/integration/query/category.ts +++ b/tests/integration/query/category.ts @@ -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 = { diff --git a/tests/integration/query/entry.ts b/tests/integration/query/entry.ts index 941a83b..f43c574 100644 --- a/tests/integration/query/entry.ts +++ b/tests/integration/query/entry.ts @@ -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); diff --git a/tests/integration/query/patient.ts b/tests/integration/query/patient.ts index fe82e1d..268e09e 100644 --- a/tests/integration/query/patient.ts +++ b/tests/integration/query/patient.ts @@ -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); +}); diff --git a/tests/integration/query/room.ts b/tests/integration/query/room.ts index 7e823fd..33e1e42 100644 --- a/tests/integration/query/room.ts +++ b/tests/integration/query/room.ts @@ -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([ diff --git a/vite.config.ts b/vite.config.ts index 49c9e7c..034f22f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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}"], },