Compare commits

...

4 commits

43 changed files with 853 additions and 170 deletions

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
sveltekit-zero-api.d.ts
/.vscode

View file

@ -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",

View file

@ -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'}

View file

@ -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;

View file

@ -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);

View file

@ -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[]

View file

@ -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
View 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;

View 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);
}
}

View file

@ -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 } });
}

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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 },

View file

@ -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(),
]);

View file

@ -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
View 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");
}
}

View file

@ -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;

View file

@ -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;
}>;

View 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);
});

View file

@ -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,
});

View file

@ -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>

View file

@ -1,5 +0,0 @@
import { json } from "@sveltejs/kit";
export const GET = () => {
return json({ hello: "world" });
};

View 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 };
});
};

View 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 };
});
};

View 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);
});
};

View 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);
});
};

View 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 };
});
};

View 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 };
});
};

View 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);
});
};

View 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 };
});
};

View 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 };
});
};

View 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 };
});
};

View 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 };
});
};

View 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 };
});
};

View 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);
});
};

View file

@ -12,6 +12,11 @@ const config = {
adapter: adapter({
precompress: true,
}),
alias: {
$api: "./src/api",
$tests: "./tests",
},
},
};

View file

@ -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();

View file

@ -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 = {

View file

@ -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);

View file

@ -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);
});

View file

@ -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([

View file

@ -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}"],
},