diff --git a/drizzle/0008_fixed_pestilence.sql b/drizzle/0008_fixed_pestilence.sql new file mode 100644 index 0000000..9cbd8d3 --- /dev/null +++ b/drizzle/0008_fixed_pestilence.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS "university" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + CONSTRAINT "university_name_unique" UNIQUE("name") +); +--> statement-breakpoint +ALTER TABLE "person" DROP CONSTRAINT "person_identifier_unique";--> statement-breakpoint +ALTER TABLE "person" ALTER COLUMN "department" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "person" ADD COLUMN "university" text;--> statement-breakpoint +ALTER TABLE "person_entry" ADD COLUMN "guarantor_id" integer;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "person" ADD CONSTRAINT "person_university_university_name_fk" FOREIGN KEY ("university") REFERENCES "public"."university"("name") ON DELETE restrict ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "person_entry" ADD CONSTRAINT "person_entry_guarantor_id_person_id_fk" FOREIGN KEY ("guarantor_id") REFERENCES "public"."person"("id") ON DELETE restrict ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "person" ADD CONSTRAINT "person_identifier_university_id_unique" UNIQUE NULLS NOT DISTINCT("identifier","university"); \ No newline at end of file diff --git a/drizzle/0009_crazy_colossus.sql b/drizzle/0009_crazy_colossus.sql new file mode 100644 index 0000000..3580e1f --- /dev/null +++ b/drizzle/0009_crazy_colossus.sql @@ -0,0 +1,8 @@ +ALTER TABLE "ratelimit" DROP CONSTRAINT "ratelimit_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "ratelimit" ADD CONSTRAINT "ratelimit_user_id_timestamp_pk" PRIMARY KEY("user_id","timestamp");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "ratelimit" ADD CONSTRAINT "ratelimit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..eeb66e7 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,454 @@ +{ + "id": "1e9e2df8-44b5-4721-b653-7db0b99c5a4e", + "prevId": "81ee13a7-fe6d-4b61-828e-edba465e15b5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.building": { + "name": "building", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "building_name_unique": { + "name": "building_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.department": { + "name": "department", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "department_name_unique": { + "name": "department_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.person": { + "name": "person", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fname": { + "name": "fname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lname": { + "name": "lname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "person_department_department_name_fk": { + "name": "person_department_department_name_fk", + "tableFrom": "person", + "tableTo": "department", + "columnsFrom": ["department"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "person_university_university_name_fk": { + "name": "person_university_university_name_fk", + "tableFrom": "person", + "tableTo": "university", + "columnsFrom": ["university"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "person_identifier_university_id_unique": { + "name": "person_identifier_university_id_unique", + "nullsNotDistinct": true, + "columns": ["identifier", "university"] + } + } + }, + "public.person_entry": { + "name": "person_entry", + "schema": "", + "columns": { + "person_id": { + "name": "person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "building": { + "name": "building", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator": { + "name": "creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guarantor_id": { + "name": "guarantor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "person_entry_person_id_person_id_fk": { + "name": "person_entry_person_id_person_id_fk", + "tableFrom": "person_entry", + "tableTo": "person", + "columnsFrom": ["person_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "person_entry_building_building_name_fk": { + "name": "person_entry_building_building_name_fk", + "tableFrom": "person_entry", + "tableTo": "building", + "columnsFrom": ["building"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "person_entry_creator_user_username_fk": { + "name": "person_entry_creator_user_username_fk", + "tableFrom": "person_entry", + "tableTo": "user", + "columnsFrom": ["creator"], + "columnsTo": ["username"], + "onDelete": "no action", + "onUpdate": "cascade" + }, + "person_entry_guarantor_id_person_id_fk": { + "name": "person_entry_guarantor_id_person_id_fk", + "tableFrom": "person_entry", + "tableTo": "person", + "columnsFrom": ["guarantor_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "person_entry_person_id_timestamp_pk": { + "name": "person_entry_person_id_timestamp_pk", + "columns": ["person_id", "timestamp"] + } + }, + "uniqueConstraints": {} + }, + "public.person_exit": { + "name": "person_exit", + "schema": "", + "columns": { + "person_id": { + "name": "person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "building": { + "name": "building", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator": { + "name": "creator", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "person_exit_person_id_person_id_fk": { + "name": "person_exit_person_id_person_id_fk", + "tableFrom": "person_exit", + "tableTo": "person", + "columnsFrom": ["person_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "person_exit_building_building_name_fk": { + "name": "person_exit_building_building_name_fk", + "tableFrom": "person_exit", + "tableTo": "building", + "columnsFrom": ["building"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "person_exit_creator_user_username_fk": { + "name": "person_exit_creator_user_username_fk", + "tableFrom": "person_exit", + "tableTo": "user", + "columnsFrom": ["creator"], + "columnsTo": ["username"], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "person_exit_person_id_timestamp_pk": { + "name": "person_exit_person_id_timestamp_pk", + "columns": ["person_id", "timestamp"] + } + }, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "building": { + "name": "building", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "session_building_building_name_fk": { + "name": "session_building_building_name_fk", + "tableFrom": "session", + "tableTo": "building", + "columnsFrom": ["building"], + "columnsTo": ["name"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.university": { + "name": "university", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "university_name_unique": { + "name": "university_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.ratelimit": { + "name": "ratelimit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lock": { + "name": "lock", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ratelimit_user_id_user_id_fk": { + "name": "ratelimit_user_id_user_id_fk", + "tableFrom": "ratelimit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..91aeb5d --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,459 @@ +{ + "id": "a3bde6c5-8f88-4006-9c29-982c51f2e436", + "prevId": "1e9e2df8-44b5-4721-b653-7db0b99c5a4e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.building": { + "name": "building", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "building_name_unique": { + "name": "building_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.department": { + "name": "department", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "department_name_unique": { + "name": "department_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.person": { + "name": "person", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fname": { + "name": "fname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lname": { + "name": "lname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "person_department_department_name_fk": { + "name": "person_department_department_name_fk", + "tableFrom": "person", + "tableTo": "department", + "columnsFrom": ["department"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "person_university_university_name_fk": { + "name": "person_university_university_name_fk", + "tableFrom": "person", + "tableTo": "university", + "columnsFrom": ["university"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "person_identifier_university_id_unique": { + "name": "person_identifier_university_id_unique", + "nullsNotDistinct": true, + "columns": ["identifier", "university"] + } + } + }, + "public.person_entry": { + "name": "person_entry", + "schema": "", + "columns": { + "person_id": { + "name": "person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "building": { + "name": "building", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator": { + "name": "creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guarantor_id": { + "name": "guarantor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "person_entry_person_id_person_id_fk": { + "name": "person_entry_person_id_person_id_fk", + "tableFrom": "person_entry", + "tableTo": "person", + "columnsFrom": ["person_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "person_entry_building_building_name_fk": { + "name": "person_entry_building_building_name_fk", + "tableFrom": "person_entry", + "tableTo": "building", + "columnsFrom": ["building"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "person_entry_creator_user_username_fk": { + "name": "person_entry_creator_user_username_fk", + "tableFrom": "person_entry", + "tableTo": "user", + "columnsFrom": ["creator"], + "columnsTo": ["username"], + "onDelete": "no action", + "onUpdate": "cascade" + }, + "person_entry_guarantor_id_person_id_fk": { + "name": "person_entry_guarantor_id_person_id_fk", + "tableFrom": "person_entry", + "tableTo": "person", + "columnsFrom": ["guarantor_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "person_entry_person_id_timestamp_pk": { + "name": "person_entry_person_id_timestamp_pk", + "columns": ["person_id", "timestamp"] + } + }, + "uniqueConstraints": {} + }, + "public.person_exit": { + "name": "person_exit", + "schema": "", + "columns": { + "person_id": { + "name": "person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "building": { + "name": "building", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator": { + "name": "creator", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "person_exit_person_id_person_id_fk": { + "name": "person_exit_person_id_person_id_fk", + "tableFrom": "person_exit", + "tableTo": "person", + "columnsFrom": ["person_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "person_exit_building_building_name_fk": { + "name": "person_exit_building_building_name_fk", + "tableFrom": "person_exit", + "tableTo": "building", + "columnsFrom": ["building"], + "columnsTo": ["name"], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "person_exit_creator_user_username_fk": { + "name": "person_exit_creator_user_username_fk", + "tableFrom": "person_exit", + "tableTo": "user", + "columnsFrom": ["creator"], + "columnsTo": ["username"], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "person_exit_person_id_timestamp_pk": { + "name": "person_exit_person_id_timestamp_pk", + "columns": ["person_id", "timestamp"] + } + }, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "building": { + "name": "building", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "session_building_building_name_fk": { + "name": "session_building_building_name_fk", + "tableFrom": "session", + "tableTo": "building", + "columnsFrom": ["building"], + "columnsTo": ["name"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.university": { + "name": "university", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "university_name_unique": { + "name": "university_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.ratelimit": { + "name": "ratelimit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lock": { + "name": "lock", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ratelimit_user_id_user_id_fk": { + "name": "ratelimit_user_id_user_id_fk", + "tableFrom": "ratelimit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "ratelimit_user_id_timestamp_pk": { + "name": "ratelimit_user_id_timestamp_pk", + "columns": ["user_id", "timestamp"] + } + }, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 04b1183..e2746d9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1735856957839, "tag": "0007_aspiring_zombie", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1737294850034, + "tag": "0008_fixed_pestilence", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1737296923867, + "tag": "0009_crazy_colossus", + "breakpoints": true } ] } diff --git a/eslint.config.js b/eslint.config.js index aa5987f..1e9e12f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,5 +30,10 @@ export default ts.config( parser: ts.parser } } + }, + { + rules: { + eqeqeq: ['error', 'always'] + } } ); diff --git a/src/app.d.ts b/src/app.d.ts index 5de50c2..0398b6e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,6 @@ import type { Database } from '$lib/server/db/connect'; import type { User } from '$lib/server/db/schema/user'; -import type { Session } from '$lib/server/db/schema/session'; +import type { Session } from '$lib/types/db'; declare global { namespace App { diff --git a/src/lib/components/custom/sidebar/admin-sidebar.svelte b/src/lib/components/custom/sidebar/admin-sidebar.svelte index bd4ae7f..cd3bf12 100644 --- a/src/lib/components/custom/sidebar/admin-sidebar.svelte +++ b/src/lib/components/custom/sidebar/admin-sidebar.svelte @@ -11,6 +11,7 @@ import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js'; import LogoLight from '$lib/assets/images/light.svg'; import LogoDark from '$lib/assets/images/dark.svg'; + import { University } from 'lucide-svelte'; // Menu items. const items = [ @@ -19,15 +20,20 @@ url: '/', icon: House }, + { + title: 'Create Building', + url: '/admin/create/building', + icon: Building + }, { title: 'Create Department', url: '/admin/create/department', icon: Cuboid }, { - title: 'Create Building', - url: '/admin/create/building', - icon: Building + title: 'Create University', + url: '/admin/create/university', + icon: University }, { title: 'Register User', diff --git a/src/lib/components/custom/sidebar/app-sidebar.svelte b/src/lib/components/custom/sidebar/app-sidebar.svelte index dbf1be8..5704776 100644 --- a/src/lib/components/custom/sidebar/app-sidebar.svelte +++ b/src/lib/components/custom/sidebar/app-sidebar.svelte @@ -11,6 +11,7 @@ ChartNoAxesCombined, ClipboardList, Github, + Handshake, House } from 'lucide-svelte'; @@ -31,6 +32,11 @@ url: '/create/employee', icon: Briefcase }, + { + title: 'Create Guest', + url: '/create/guest', + icon: Handshake + }, { title: 'Statistics', url: '/statistics', diff --git a/src/lib/components/ui/command/command-dialog.svelte b/src/lib/components/ui/command/command-dialog.svelte new file mode 100644 index 0000000..bacb091 --- /dev/null +++ b/src/lib/components/ui/command/command-dialog.svelte @@ -0,0 +1,35 @@ + + + + + + + diff --git a/src/lib/components/ui/command/command-empty.svelte b/src/lib/components/ui/command/command-empty.svelte new file mode 100644 index 0000000..5359600 --- /dev/null +++ b/src/lib/components/ui/command/command-empty.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/ui/command/command-group.svelte b/src/lib/components/ui/command/command-group.svelte new file mode 100644 index 0000000..fd159c6 --- /dev/null +++ b/src/lib/components/ui/command/command-group.svelte @@ -0,0 +1,27 @@ + + + + {#if heading} + + {heading} + + {/if} + + diff --git a/src/lib/components/ui/command/command-input.svelte b/src/lib/components/ui/command/command-input.svelte new file mode 100644 index 0000000..e034215 --- /dev/null +++ b/src/lib/components/ui/command/command-input.svelte @@ -0,0 +1,25 @@ + + +
+ + +
diff --git a/src/lib/components/ui/command/command-item.svelte b/src/lib/components/ui/command/command-item.svelte new file mode 100644 index 0000000..edd265d --- /dev/null +++ b/src/lib/components/ui/command/command-item.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/command/command-link-item.svelte b/src/lib/components/ui/command/command-link-item.svelte new file mode 100644 index 0000000..e608ab5 --- /dev/null +++ b/src/lib/components/ui/command/command-link-item.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/command/command-list.svelte b/src/lib/components/ui/command/command-list.svelte new file mode 100644 index 0000000..a861889 --- /dev/null +++ b/src/lib/components/ui/command/command-list.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/command/command-separator.svelte b/src/lib/components/ui/command/command-separator.svelte new file mode 100644 index 0000000..b9224d6 --- /dev/null +++ b/src/lib/components/ui/command/command-separator.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/ui/command/command-shortcut.svelte b/src/lib/components/ui/command/command-shortcut.svelte new file mode 100644 index 0000000..ace2d5f --- /dev/null +++ b/src/lib/components/ui/command/command-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/command/command.svelte b/src/lib/components/ui/command/command.svelte new file mode 100644 index 0000000..58642ab --- /dev/null +++ b/src/lib/components/ui/command/command.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/command/index.ts b/src/lib/components/ui/command/index.ts new file mode 100644 index 0000000..dee2364 --- /dev/null +++ b/src/lib/components/ui/command/index.ts @@ -0,0 +1,40 @@ +import { Command as CommandPrimitive } from 'bits-ui'; + +import Root from './command.svelte'; +import Dialog from './command-dialog.svelte'; +import Empty from './command-empty.svelte'; +import Group from './command-group.svelte'; +import Item from './command-item.svelte'; +import Input from './command-input.svelte'; +import List from './command-list.svelte'; +import Separator from './command-separator.svelte'; +import Shortcut from './command-shortcut.svelte'; +import LinkItem from './command-link-item.svelte'; + +const Loading = CommandPrimitive.Loading; + +export { + Root, + Dialog, + Empty, + Group, + Item, + LinkItem, + Input, + List, + Separator, + Shortcut, + Loading, + // + Root as Command, + Dialog as CommandDialog, + Empty as CommandEmpty, + Group as CommandGroup, + Item as CommandItem, + LinkItem as CommandLinkItem, + Input as CommandInput, + List as CommandList, + Separator as CommandSeparator, + Shortcut as CommandShortcut, + Loading as CommandLoading +}; diff --git a/src/lib/components/ui/popover/index.ts b/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..5db432e --- /dev/null +++ b/src/lib/components/ui/popover/index.ts @@ -0,0 +1,17 @@ +import { Popover as PopoverPrimitive } from 'bits-ui'; +import Content from './popover-content.svelte'; +const Root = PopoverPrimitive.Root; +const Trigger = PopoverPrimitive.Trigger; +const Close = PopoverPrimitive.Close; + +export { + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose +}; diff --git a/src/lib/components/ui/popover/popover-content.svelte b/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..7265cc3 --- /dev/null +++ b/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/src/lib/server/db/department.ts b/src/lib/server/db/department.ts index 3e38b75..0bddb0c 100644 --- a/src/lib/server/db/department.ts +++ b/src/lib/server/db/department.ts @@ -3,7 +3,7 @@ import type { Department } from '$lib/types/db'; import { department } from './schema/department'; export async function getDepartments(db: Database): Promise { - return await db.select().from(department); + return await db.select().from(department).orderBy(department.name); } export async function createDepartment(db: Database, name: string): Promise { diff --git a/src/lib/server/db/person.ts b/src/lib/server/db/person.ts index dd1bdbc..e7db9f7 100644 --- a/src/lib/server/db/person.ts +++ b/src/lib/server/db/person.ts @@ -1,5 +1,5 @@ import type { Database } from './connect'; -import { or, eq, and, max, gt, count, isNull } from 'drizzle-orm'; +import { or, eq, and, max, gt, count, isNull, not } from 'drizzle-orm'; import { person, personEntry, personExit } from './schema/person'; import { StateInside, StateOutside, type State } from '$lib/types/state'; import { fuzzySearchFilters } from './fuzzysearch'; @@ -7,39 +7,48 @@ import { sqlConcat, sqlLeast, sqlLevenshteinDistance } from './utils'; import { isInside } from '../isInside'; import { capitalizeString, sanitizeString } from '$lib/utils/sanitize'; import { building } from './schema/building'; -import { isPersonType, type PersonType } from '$lib/types/person'; +import { + Employee, + Guest, + isPersonType, + Student, + type Person, + type PersonType +} from '$lib/types/person'; + +type searchOptions = { + searchQuery?: string; + nonGuestsOnly?: boolean; +}; // Gets all persons using optional filters export async function getPersons( db: Database, limit: number, offset: number, - searchQuery?: string -): Promise< - { - id: number; - identifier: string; - type: PersonType; - fname: string; - lname: string; - department: string; - building: string | null; - state: State; - }[] -> { + opts: searchOptions = {} +): Promise { // Assert limit is valid - if (limit === null || limit === undefined || limit <= 0) { - throw new Error('Invalid limit'); + if (limit <= 0) { + throw new Error('Invalid limit (negative or zero)'); + } + + // Assert offset is valid + if (offset < 0) { + throw new Error('Invalid offset (negative)'); } // Don't search if the search query is empty when trimmed - const sanitizedSearchQuery = searchQuery ? sanitizeString(searchQuery) : undefined; + const sanitizedSearchQuery = opts.searchQuery ? sanitizeString(opts.searchQuery) : undefined; const nonEmptySearchQuery = sanitizedSearchQuery ? sanitizedSearchQuery !== '' ? sanitizedSearchQuery : undefined : undefined; + // Wether to search for persons that aren't of type "Guest" + const nonGuestsOnly = opts.nonGuestsOnly ?? false; + try { const maxEntrySubquery = db .select({ @@ -60,8 +69,10 @@ export async function getPersons( fname: person.fname, lname: person.lname, department: person.department, + university: person.university, entryTimestamp: maxEntrySubquery.maxEntryTimestamp, entryBuilding: personEntry.building, + entryGuarantorId: personEntry.guarantorId, exitTimestamp: max(personExit.timestamp), leastDistance: sqlLeast([ sqlLevenshteinDistance(sqlConcat([person.identifier]), nonEmptySearchQuery), @@ -92,18 +103,21 @@ export async function getPersons( ) .leftJoin(personExit, eq(person.id, personExit.personId)) .where( - or( - ...[ - ...fuzzySearchFilters([person.identifier], nonEmptySearchQuery), - ...fuzzySearchFilters([person.fname], nonEmptySearchQuery, { distance: 5 }), - ...fuzzySearchFilters([person.lname], nonEmptySearchQuery, { distance: 5 }), - ...fuzzySearchFilters([person.fname, person.lname], nonEmptySearchQuery, { - distance: 6 - }), - ...fuzzySearchFilters([person.lname, person.fname], nonEmptySearchQuery, { - distance: 6 - }) - ] + and( + nonGuestsOnly ? not(eq(person.type, Guest)) : undefined, + or( + ...[ + ...fuzzySearchFilters([person.identifier], nonEmptySearchQuery), + ...fuzzySearchFilters([person.fname], nonEmptySearchQuery, { distance: 5 }), + ...fuzzySearchFilters([person.lname], nonEmptySearchQuery, { distance: 5 }), + ...fuzzySearchFilters([person.fname, person.lname], nonEmptySearchQuery, { + distance: 6 + }), + ...fuzzySearchFilters([person.lname, person.fname], nonEmptySearchQuery, { + distance: 6 + }) + ] + ) ) ) .groupBy( @@ -113,8 +127,10 @@ export async function getPersons( person.fname, person.lname, person.department, + person.university, maxEntrySubquery.maxEntryTimestamp, - personEntry.building + personEntry.building, + personEntry.guarantorId ) .orderBy(({ leastDistance, leastDistanceIdentifier, identifier }) => [ leastDistance, @@ -131,8 +147,10 @@ export async function getPersons( fname: person.fname, lname: person.lname, department: person.department, + university: person.university, entryTimestamp: maxEntrySubquery.maxEntryTimestamp, entryBuilding: personEntry.building, + entryGuarantorId: personEntry.guarantorId, exitTimestamp: max(personExit.timestamp) }) .from(person) @@ -145,6 +163,7 @@ export async function getPersons( ) ) .leftJoin(personExit, eq(person.id, personExit.personId)) + .where(nonGuestsOnly ? not(eq(person.type, Guest)) : undefined) .groupBy( person.id, person.identifier, @@ -152,23 +171,32 @@ export async function getPersons( person.fname, person.lname, person.department, + person.university, maxEntrySubquery.maxEntryTimestamp, - personEntry.building + personEntry.building, + personEntry.guarantorId ) .orderBy(({ identifier }) => [identifier]) .limit(limit) .offset(offset); - return persons.map((s) => { + return persons.map((p) => { + if (!isPersonType(p.type)) { + throw new Error('Invalid type from DB (not PersonType)'); + } + + const inside = isInside(p.entryTimestamp, p.exitTimestamp); return { - id: s.id, - identifier: s.identifier, - type: s.type as PersonType, - fname: s.fname, - lname: s.lname, - department: s.department, - building: isInside(s.entryTimestamp, s.exitTimestamp) ? s.entryBuilding : null, - state: isInside(s.entryTimestamp, s.exitTimestamp) ? StateInside : StateOutside + id: p.id, + identifier: p.identifier, + type: p.type, + fname: p.fname, + lname: p.lname, + department: p.department, + university: p.university, + building: inside ? p.entryBuilding : null, + guarantorId: inside ? p.entryGuarantorId : null, + state: inside ? StateInside : StateOutside }; }); } catch (err: unknown) { @@ -176,6 +204,39 @@ export async function getPersons( } } +// Gets the count of all persons per type +export async function getPersonsCountPerType(db: Database): Promise< + { + type: PersonType; + count: number; + }[] +> { + try { + const persons = await db + .select({ + type: person.type, + count: count() + }) + .from(person) + .groupBy(person.type) + .orderBy(({ count }) => count); + + return persons.map((p) => { + if (p.type !== null && !isPersonType(p.type)) { + throw new Error('Invalid type from DB (not PersonType)'); + } + return { + type: p.type, + count: p.count + }; + }); + } catch (err: unknown) { + throw new Error( + `Failed to get count of persons per type from database: ${(err as Error).message}}` + ); + } +} + // Gets the count of all persons per department export async function getPersonsCountPerDepartment(db: Database): Promise< { @@ -192,7 +253,9 @@ export async function getPersonsCountPerDepartment(db: Database): Promise< count: count() }) .from(person) - .groupBy(person.type, person.department); + .where(not(eq(person.type, Guest))) + .groupBy(person.type, person.department) + .orderBy(({ count }) => count); return persons.map((p) => { if (p.type !== null && !isPersonType(p.type)) { @@ -200,12 +263,51 @@ export async function getPersonsCountPerDepartment(db: Database): Promise< } return { type: p.type, - department: p.department, + department: p.department ?? 'None', + count: p.count + }; + }); + } catch (err: unknown) { + throw new Error( + `Failed to get count of persons per department from database: ${(err as Error).message}}` + ); + } +} + +// Gets the count of all persons per university +export async function getPersonsCountPerUniversity(db: Database): Promise< + { + type: PersonType; + university: string; + count: number; + }[] +> { + try { + const persons = await db + .select({ + type: person.type, + university: person.university, + count: count() + }) + .from(person) + .where(eq(person.type, Guest)) + .groupBy(person.type, person.university) + .orderBy(({ count }) => count); + + return persons.map((p) => { + if (p.type !== null && !isPersonType(p.type)) { + throw new Error('Invalid type from DB (not PersonType)'); + } + return { + type: p.type, + university: p.university ?? 'None', count: p.count }; }); } catch (err: unknown) { - throw new Error(`Failed to get count of persons from database: ${(err as Error).message}}`); + throw new Error( + `Failed to get count of persons per university from database: ${(err as Error).message}}` + ); } } @@ -214,7 +316,7 @@ export async function getPersonsCountPerBuilding(db: Database): Promise< { type: PersonType | null; building: string; - insideCount: number; + count: number; }[] > { try { @@ -239,11 +341,12 @@ export async function getPersonsCountPerBuilding(db: Database): Promise< .select({ type: personInsideSubquery.type, building: building.name, - insideCount: count(personInsideSubquery.personId) + count: count(personInsideSubquery.personId) }) .from(building) .leftJoin(personInsideSubquery, eq(building.name, personInsideSubquery.entryBuilding)) - .groupBy(personInsideSubquery.type, building.name); + .groupBy(personInsideSubquery.type, building.name) + .orderBy(({ count }) => count); return persons.map((p) => { if (p.type !== null && !isPersonType(p.type)) { @@ -252,16 +355,77 @@ export async function getPersonsCountPerBuilding(db: Database): Promise< return { type: p.type, building: p.building, - insideCount: p.insideCount + count: p.count }; }); } catch (err: unknown) { throw new Error( - `Failed to get inside count of persons from database: ${(err as Error).message}}` + `Failed to get inside count of persons per building from database: ${(err as Error).message}}` ); } } +// Creates an employee (person) +export async function createEmployee( + db: Database, + identifier: string, + fname: string, + lname: string, + department: string | undefined, + building: string, + creator: string +): Promise { + return await createPerson(db, identifier, Employee, fname, lname, building, creator, { + department + }); +} + +// Creates a student (person) +export async function createStudent( + db: Database, + identifier: string, + fname: string, + lname: string, + department: string | undefined, + building: string, + creator: string +): Promise { + return await createPerson(db, identifier, Student, fname, lname, building, creator, { + department + }); +} + +// Creates a guest (person) +export async function createGuest( + db: Database, + identifier: string, + fname: string, + lname: string, + university: string | undefined, + building: string, + creator: string, + guarantorId: number | undefined +): Promise { + return await createPerson(db, identifier, Guest, fname, lname, building, creator, { + university, + guarantorId + }); +} + +// Checks if a guarantor exists in the database and is not a guest +async function validGuarantor(db: Database, guarantorId: number): Promise { + const [{ type }] = await db + .select({ type: person.type }) + .from(person) + .where(eq(person.id, guarantorId)); + + if (!isPersonType(type)) { + throw new Error('Invalid type from DB (not PersonType)'); + } + + return type !== Guest; +} + // Creates a person and the entry timestamp export async function createPerson( db: Database, @@ -269,44 +433,56 @@ export async function createPerson( type: PersonType, fnameD: string, lnameD: string, - department: string, building: string, - creator: string -): Promise<{ - id: number; - identifier: string; - type: PersonType; - fname: string; - lname: string; - department: string; - building: string; - state: State; -}> { - // Assert fname, lname and identifier are valid - if ( - identifierD === null || - identifierD === undefined || - identifierD === '' || - type === null || - type === undefined || - (type as string) === '' || - fnameD === null || - fnameD === undefined || - fnameD === '' || - lnameD === null || - lnameD === undefined || - lnameD === '' || - department === null || - department === undefined || - department === '' || - building === null || - building === undefined || - building === '' || - creator === null || - creator === undefined || - creator === '' - ) { - throw new Error('Invalid person data'); + creator: string, + opts: { + department?: string; + university?: string; + guarantorId?: number; + } +): Promise { + const department = opts.department ?? null; + const university = opts.university ?? null; + const guarantorId = opts.guarantorId ?? null; + + // Assert identifier is valid + if (identifierD === '') { + throw new Error('Invalid identifier (empty)'); + } + + // Assert type is valid + if ((type as string) === '') { + throw new Error('Invalid type (empty)'); + } + + // Assert fname is valid + if (fnameD === '') { + throw new Error('Invalid fname (empty)'); + } + + // Assert lname is valid + if (lnameD === '') { + throw new Error('Invalid lname (empty)'); + } + + // Assert department is valid + if (department && department === '') { + throw new Error('Invalid department (empty)'); + } + + // Assert university is valid + if (university && university === '') { + throw new Error('Invalid university (empty)'); + } + + // Assert building is valid + if (building === '') { + throw new Error('Invalid building (empty)'); + } + + // Assert creator is valid + if (creator === '') { + throw new Error('Invalid creator (empty)'); } const identifier = sanitizeString(identifierD); @@ -315,17 +491,23 @@ export async function createPerson( try { return await db.transaction(async (tx) => { + // Check if the guarantor is valid + if (guarantorId && !validGuarantor(tx, guarantorId)) { + throw new Error('Guarantor not valid'); + } + // Create the person const [{ id }] = await tx .insert(person) - .values({ identifier, type, fname, lname, department }) + .values({ identifier, type, fname, lname, department, university }) .returning({ id: person.id }); // Create the person entry await tx.insert(personEntry).values({ personId: id, building, - creator + creator, + guarantorId }); return { @@ -335,7 +517,9 @@ export async function createPerson( fname, lname, department, + university, building, + guarantorId, state: StateInside // Because the person was just created, they are inside }; }); @@ -349,24 +533,26 @@ export async function togglePersonState( db: Database, id: number, building: string, - creator: string + creator: string, + guarantorId?: number ): Promise { - // Assert id, building and creator are valid - if ( - id === null || - id === undefined || - building === null || - building === undefined || - building === '' || - creator === null || - creator === undefined || - creator === '' - ) { - throw new Error('Invalid person data'); + // Assert building is valid + if (building === '') { + throw new Error('Invalid building (empty)'); + } + + // Assert creator is valid + if (creator === '') { + throw new Error('Invalid creator (empty)'); } try { return await db.transaction(async (tx) => { + // Check if the guarantor is valid + if (guarantorId && !validGuarantor(tx, guarantorId)) { + throw new Error('Guarantor not valid'); + } + // Get the person entry and exit timestamps const [{ entryTimestamp, exitTimestamp }] = await tx .select({ @@ -394,7 +580,8 @@ export async function togglePersonState( await tx.insert(personEntry).values({ personId: id, building, - creator + creator, + guarantorId }); return StateInside; } @@ -426,6 +613,16 @@ export async function removePersonsFromBuilding( building: string, type: string ): Promise { + // Assert building is valid + if (building === '') { + throw new Error('Invalid building (empty)'); + } + + // Assert type is valid + if (type === '') { + throw new Error('Invalid type (empty)'); + } + try { return await db.transaction(async (tx) => { const personsInside = await tx diff --git a/src/lib/server/db/schema/person.ts b/src/lib/server/db/schema/person.ts index 7873a0b..80ae65a 100644 --- a/src/lib/server/db/schema/person.ts +++ b/src/lib/server/db/schema/person.ts @@ -1,18 +1,32 @@ -import { pgTable, serial, integer, text, timestamp, primaryKey } from 'drizzle-orm/pg-core'; -import { building } from './building'; +import { pgTable, serial, integer, text, timestamp, primaryKey, unique } from 'drizzle-orm/pg-core'; import { department } from './department'; +import { university } from './university'; +import { building } from './building'; import { userTable } from './user'; -export const person = pgTable('person', { - id: serial('id').primaryKey(), - identifier: text('identifier').notNull().unique(), - type: text('type').notNull(), - fname: text('fname').notNull(), - lname: text('lname').notNull(), - department: text('department') - .notNull() - .references(() => department.name, { onDelete: 'restrict', onUpdate: 'cascade' }) -}); +export const person = pgTable( + 'person', + { + id: serial('id').primaryKey(), + identifier: text('identifier').notNull(), + type: text('type').notNull(), + fname: text('fname').notNull(), + lname: text('lname').notNull(), + department: text('department').references(() => department.name, { + onDelete: 'restrict', + onUpdate: 'cascade' + }), + university: text('university').references(() => university.name, { + onDelete: 'restrict', + onUpdate: 'cascade' + }) + }, + (table) => ({ + identifierUniversity: unique('person_identifier_university_id_unique') + .on(table.identifier, table.university) + .nullsNotDistinct() + }) +); export const personEntry = pgTable( 'person_entry', @@ -27,13 +41,15 @@ export const personEntry = pgTable( creator: text('creator').references(() => userTable.username, { onDelete: 'no action', onUpdate: 'cascade' + }), + guarantorId: integer('guarantor_id').references(() => person.id, { + onDelete: 'restrict', + onUpdate: 'cascade' }) }, - (table) => { - return { - pk: primaryKey({ columns: [table.personId, table.timestamp] }) - }; - } + (table) => ({ + pk: primaryKey({ columns: [table.personId, table.timestamp] }) + }) ); export const personExit = pgTable( @@ -51,9 +67,7 @@ export const personExit = pgTable( onUpdate: 'cascade' }) }, - (table) => { - return { - pk: primaryKey({ columns: [table.personId, table.timestamp] }) - }; - } + (table) => ({ + pk: primaryKey({ columns: [table.personId, table.timestamp] }) + }) ); diff --git a/src/lib/server/db/schema/session.ts b/src/lib/server/db/schema/session.ts index 6244a2d..3c70b57 100644 --- a/src/lib/server/db/schema/session.ts +++ b/src/lib/server/db/schema/session.ts @@ -1,4 +1,3 @@ -import type { InferSelectModel } from 'drizzle-orm'; import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core'; import { userTable } from './user'; import { building } from './building'; @@ -13,5 +12,3 @@ export const sessionTable = pgTable('session', { .references(() => building.name), timestamp: timestamp('timestamp').defaultNow().notNull() }); - -export type Session = InferSelectModel; diff --git a/src/lib/server/db/schema/university.ts b/src/lib/server/db/schema/university.ts new file mode 100644 index 0000000..12409b7 --- /dev/null +++ b/src/lib/server/db/schema/university.ts @@ -0,0 +1,6 @@ +import { pgTable, serial, text } from 'drizzle-orm/pg-core'; + +export const university = pgTable('university', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique() +}); diff --git a/src/lib/server/db/schema/user.ts b/src/lib/server/db/schema/user.ts index 0656147..33ac227 100644 --- a/src/lib/server/db/schema/user.ts +++ b/src/lib/server/db/schema/user.ts @@ -1,5 +1,12 @@ -import type { InferSelectModel } from 'drizzle-orm'; -import { boolean, integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; +import { + boolean, + integer, + pgTable, + primaryKey, + serial, + text, + timestamp +} from 'drizzle-orm/pg-core'; export const userTable = pgTable('user', { id: serial('id').primaryKey(), @@ -7,12 +14,16 @@ export const userTable = pgTable('user', { passwordHash: text('password_hash').notNull() }); -export const ratelimitTable = pgTable('ratelimit', { - userId: integer('user_id') - .notNull() - .references(() => userTable.id, { onDelete: 'cascade' }), - timestamp: timestamp('timestamp').defaultNow().notNull(), - lock: boolean('lock').notNull() -}); - -export type User = InferSelectModel; +export const ratelimitTable = pgTable( + 'ratelimit', + { + userId: integer('user_id') + .notNull() + .references(() => userTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + timestamp: timestamp('timestamp').defaultNow().notNull(), + lock: boolean('lock').notNull() + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.timestamp] }) + }) +); diff --git a/src/lib/server/db/session.ts b/src/lib/server/db/session.ts index 485f3a9..f828116 100644 --- a/src/lib/server/db/session.ts +++ b/src/lib/server/db/session.ts @@ -2,9 +2,14 @@ import type { Database } from './connect'; import { and, desc, eq, notInArray } from 'drizzle-orm'; import { encodeHexLowerCase } from '@oslojs/encoding'; import { sha256 } from '@oslojs/crypto/sha2'; -import { userTable, type User } from './schema/user'; -import { sessionTable, type Session } from './schema/session'; +import { userTable } from './schema/user'; +import { sessionTable } from './schema/session'; import { env } from '$env/dynamic/private'; +import type { Session, User } from '$lib/types/db'; + +export type SessionValidationResult = + | { session: Session; user: User } + | { session: null; user: null }; export const inactivityTimeout = Number.parseInt(env.INACTIVITY_TIMEOUT ?? '120') * 60 * 1000; export const maxActiveSessions = Number.parseInt(env.MAX_ACTIVE_SESSIONS ?? '3'); @@ -16,30 +21,23 @@ export async function createSession( building: string ): Promise { // Assert that token is valid - if (token === null || token === undefined || token === '') { - throw new Error('Invalid token'); - } - - // Assert that userId is valid - if (userId === null || userId === undefined) { - throw new Error('Invalid userId'); + if (token === '') { + throw new Error('Invalid token (empty)'); } // Assert that building is valid - if (building === null || building === undefined || building === '') { - throw new Error('Invalid token'); + if (building === '') { + throw new Error('Invalid building (empty)'); } try { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const result = await db + const [result] = await db .insert(sessionTable) .values({ id: sessionId, userId, building }) .returning(); - if (result.length !== 1) { - throw new Error('Insert length is not 1'); - } - return result[0]; + + return result; } catch (err: unknown) { throw new Error(`Failed to create session in database: ${(err as Error).message}`); } @@ -50,8 +48,8 @@ export async function validateSessionToken( token: string ): Promise { // Assert that token is valid - if (token === null || token === undefined || token === '') { - throw new Error('Invalid token'); + if (token === '') { + throw new Error('Invalid token (empty)'); } try { @@ -85,8 +83,8 @@ export async function validateSessionToken( export async function invalidateSession(db: Database, sessionId: string): Promise { // Assert that sessionId is valid - if (sessionId === null || sessionId === undefined || sessionId === '') { - throw new Error('Invalid sessionId'); + if (sessionId === '') { + throw new Error('Invalid sessionId (empty)'); } try { @@ -98,11 +96,6 @@ export async function invalidateSession(db: Database, sessionId: string): Promis // Invalidate sessions that exceed the maximum number of sessions export async function invalidateExcessSessions(db: Database, userId: number): Promise { - // Assert that userId is valid - if (userId === null || userId === undefined) { - throw new Error('Invalid userId'); - } - const newestSessions = db .select({ id: sessionTable.id @@ -116,7 +109,3 @@ export async function invalidateExcessSessions(db: Database, userId: number): Pr .delete(sessionTable) .where(and(eq(sessionTable.userId, userId), notInArray(sessionTable.id, newestSessions))); } - -export type SessionValidationResult = - | { session: Session; user: User } - | { session: null; user: null }; diff --git a/src/lib/server/db/university.ts b/src/lib/server/db/university.ts new file mode 100644 index 0000000..d60ffe0 --- /dev/null +++ b/src/lib/server/db/university.ts @@ -0,0 +1,18 @@ +import type { Database } from './connect'; +import type { University } from '$lib/types/db'; +import { university } from './schema/university'; + +export async function getUniversities(db: Database): Promise { + return await db.select().from(university).orderBy(university.name); +} + +export async function createUniversity(db: Database, name: string): Promise { + // Assert that name is valid + if (name === null || name === undefined || name === '') { + throw new Error('Invalid name'); + } + + await db.insert(university).values({ + name + }); +} diff --git a/src/lib/server/db/user.ts b/src/lib/server/db/user.ts index 838d592..433d9e4 100644 --- a/src/lib/server/db/user.ts +++ b/src/lib/server/db/user.ts @@ -5,13 +5,13 @@ import { hashPassword, verifyPasswordStrength } from '../password'; export async function createUser(db: Database, username: string, password: string): Promise { // Assert that username is valid - if (username === undefined || username === null || username === '') { - throw new Error('Invalid username'); + if (username === '') { + throw new Error('Invalid username (empty)'); } // Assert that password is valid - if (password === undefined || password === null || password === '') { - throw new Error('Invalid password'); + if (password === '') { + throw new Error('Invalid password (empty)'); } // Check the strength of the password @@ -36,8 +36,8 @@ export async function getUserIdAndPasswordHash( username: string ): Promise<{ id: number; passwordHash: string }> { // Assert that username is valid - if (username === null || username === undefined || username === '') { - throw new Error('Invalid username'); + if (username === '') { + throw new Error('Invalid username (empty)'); } try { @@ -67,23 +67,14 @@ export async function checkUserRatelimit( ratelimitMaxAttempts: number, ratelimitTimeout: number ): Promise { - // Assert that id is valid - if (userId === null || userId === undefined) { - throw new Error('Invalid userId'); - } - // Assert that ratelimitMaxAttempts is valid - if ( - ratelimitMaxAttempts === null || - ratelimitMaxAttempts === undefined || - ratelimitMaxAttempts <= 0 - ) { - throw new Error('Invalid ratelimitMaxAttempts'); + if (ratelimitMaxAttempts <= 0) { + throw new Error('Invalid ratelimitMaxAttempts (negative)'); } // Assert that ratelimitTimeout is valid - if (ratelimitTimeout === null || ratelimitTimeout === undefined || ratelimitTimeout <= 0) { - throw new Error('Invalid ratelimitTimeout'); + if (ratelimitTimeout <= 0) { + throw new Error('Invalid ratelimitTimeout (negative)'); } const ratelimitTimeoutMS = ratelimitTimeout * 1000; diff --git a/src/lib/types/db.ts b/src/lib/types/db.ts index 1ba5bb1..792490b 100644 --- a/src/lib/types/db.ts +++ b/src/lib/types/db.ts @@ -1,9 +1,12 @@ -export type Building = { - id: number; - name: string; -}; +import type { building } from '$lib/server/db/schema/building'; +import type { department } from '$lib/server/db/schema/department'; +import type { sessionTable } from '$lib/server/db/schema/session'; +import type { university } from '$lib/server/db/schema/university'; +import type { userTable } from '$lib/server/db/schema/user'; +import type { InferSelectModel } from 'drizzle-orm'; -export type Department = { - id: number; - name: string; -}; +export type Building = InferSelectModel; +export type Department = InferSelectModel; +export type Session = InferSelectModel; +export type University = InferSelectModel; +export type User = InferSelectModel; diff --git a/src/lib/types/person.ts b/src/lib/types/person.ts index 3619032..1b26902 100644 --- a/src/lib/types/person.ts +++ b/src/lib/types/person.ts @@ -1,13 +1,15 @@ import type { State } from './state'; -export type PersonType = 'Student' | 'Employee'; -export const Student: PersonType = 'Student'; +export type PersonType = 'Employee' | 'Guest' | 'Student'; export const Employee: PersonType = 'Employee'; +export const Guest: PersonType = 'Guest'; +export const Student: PersonType = 'Student'; export function isPersonType(s: string): s is PersonType { switch (s) { - case Student: case Employee: + case Guest: + case Student: return true; default: return false; @@ -20,7 +22,9 @@ export type Person = { identifier: string; fname: string; lname: string; - department: string; + department: string | null; + university: string | null; building: string | null; + guarantorId: number | null; state: State; }; diff --git a/src/lib/types/state.ts b/src/lib/types/state.ts index e773b17..edacf31 100644 --- a/src/lib/types/state.ts +++ b/src/lib/types/state.ts @@ -2,6 +2,12 @@ export type State = 'Inside' | 'Outside'; export const StateInside: State = 'Inside'; export const StateOutside: State = 'Outside'; -export function isStateType(value: string): value is State { - return value === StateInside || value === StateOutside; +export function isStateType(s: string): s is State { + switch (s) { + case StateInside: + case StateOutside: + return true; + default: + return false; + } } diff --git a/src/lib/utils/regexp.ts b/src/lib/utils/regexp.ts index 69052d0..e22d2e9 100644 --- a/src/lib/utils/regexp.ts +++ b/src/lib/utils/regexp.ts @@ -8,10 +8,6 @@ export const indexRegExp = new RegExp( ); export const indexRegExpMsg = 'String must follow Student ID format'; -export const uppercaseRegExp = /^[A-Z0-9_\-+/\\|]*$/; -export const uppercaseRegExpMsg = - 'String can consist of uppercase letters, numbers, and symbols (_, -, +, /, \\, |)'; - -export const lowercaseRegExp = /^[a-z0-9_\-+/\\|]*$/; -export const lowercaseRegExpMsg = - 'String can consist of lowercase letters, numbers, and symbols (_, -, +, /, \\, |)'; +export const wordRegExp = /^[\w\-+/\\|][\w\-+/\\|\s]*$/; +export const wordRegExpMsg = + 'String can consist of letters, numbers, symbols (_, -, +, /, \\, |) and whitespaces'; diff --git a/src/routes/(dashboard)/+layout.svelte b/src/routes/(dashboard)/+layout.svelte index 8e6c509..37f1af7 100644 --- a/src/routes/(dashboard)/+layout.svelte +++ b/src/routes/(dashboard)/+layout.svelte @@ -21,6 +21,8 @@ Create Student {:else if $page.url.pathname === '/create/employee'} Create Employee + {:else if $page.url.pathname === '/create/guest'} + Create Guest {:else if $page.url.pathname === '/statistics'} Statistics {:else if $page.url.pathname === '/instructions'} diff --git a/src/routes/(dashboard)/+page.server.ts b/src/routes/(dashboard)/+page.server.ts index c2f4a8d..13207ac 100644 --- a/src/routes/(dashboard)/+page.server.ts +++ b/src/routes/(dashboard)/+page.server.ts @@ -33,9 +33,8 @@ export const actions: Actions = { }); } - const persons = await getPersons(database, 1000, 0, searchQuery); + const persons = await getPersons(database, 1000, 0, { searchQuery }); return { - searchQuery, persons }; } catch (err: unknown) { @@ -50,7 +49,6 @@ export const actions: Actions = { try { const formData = await request.formData(); const idS = formData.get('id'); - const type = formData.get('type'); const searchQuery = formData.get('q'); // Check if the id, type and searchQuery are valid @@ -59,10 +57,6 @@ export const actions: Actions = { idS === undefined || typeof idS !== 'string' || idS === '' || - type === null || - type === undefined || - typeof type !== 'string' || - type === '' || searchQuery === null || searchQuery === undefined || typeof searchQuery !== 'string' @@ -83,7 +77,7 @@ export const actions: Actions = { const id = Number.parseInt(idS); await togglePersonState(database, id, building, username); - const persons = await getPersons(database, 1000, 0, searchQuery); + const persons = await getPersons(database, 1000, 0, { searchQuery }); return { searchQuery, persons, diff --git a/src/routes/(dashboard)/+page.svelte b/src/routes/(dashboard)/+page.svelte index d4a453e..1f52825 100644 --- a/src/routes/(dashboard)/+page.svelte +++ b/src/routes/(dashboard)/+page.svelte @@ -11,7 +11,6 @@ import { page } from '$app/stores'; import { columns } from './columns'; import { enhance } from '$app/forms'; - import { goto } from '$app/navigation'; import { searchStore } from '$lib/stores/search.svelte'; let { data, form: actionData } = $props(); @@ -34,6 +33,10 @@ let liveSearch: boolean = $state(true); let searchQuery = $state(''); + $effect(() => { + searchStore.query = searchQuery; + }); + const persons = $derived(actionData?.persons ?? data.persons ?? []); @@ -43,19 +46,10 @@ action="?/search" class="flex gap-2 px-4 py-2" onreset={() => { - searchStore.query = ''; - goto('/'); + searchQuery = ''; + searchForm?.requestSubmit(); }} - use:enhance={({ formData }) => { - const input = formData.get('q'); - - // Check if the input is valid - if (input === null || input === undefined || typeof input !== 'string') { - toast.error('Invalid search query'); - } else { - searchStore.query = input; - } - + use:enhance={() => { return async ({ update }) => { await update({ reset: false }); }; diff --git a/src/routes/(dashboard)/columns.ts b/src/routes/(dashboard)/columns.ts index f72a515..c67d811 100644 --- a/src/routes/(dashboard)/columns.ts +++ b/src/routes/(dashboard)/columns.ts @@ -24,6 +24,10 @@ export const columns: ColumnDef[] = [ accessorKey: 'department', header: 'Department' }, + { + accessorKey: 'university', + header: 'University' + }, { accessorKey: 'building', header: 'Building' @@ -38,7 +42,7 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { return renderComponent(DataTableActions, { id: row.original.id, - type: row.original.type + guarantorId: row.original.guarantorId }); }, enableSorting: false diff --git a/src/routes/(dashboard)/create/employee/+page.server.ts b/src/routes/(dashboard)/create/employee/+page.server.ts index aa7f8b3..b1479cb 100644 --- a/src/routes/(dashboard)/create/employee/+page.server.ts +++ b/src/routes/(dashboard)/create/employee/+page.server.ts @@ -2,12 +2,11 @@ import { fail, type Actions } from '@sveltejs/kit'; import { superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; -import { createPerson } from '$lib/server/db/person'; +import { createEmployee } from '$lib/server/db/person'; import { getDepartments } from '$lib/server/db/department'; import { formSchema } from './schema'; import type { PageServerLoad } from './$types'; -import { Employee } from '$lib/types/person'; export const load: PageServerLoad = async ({ locals }) => { const { database } = locals; @@ -40,10 +39,9 @@ export const actions: Actions = { try { const { identifier, fname, lname, department } = form.data; - const type = Employee; const building = locals.session.building; const creator = locals.user.username; - await createPerson(database, identifier, type, fname, lname, department, building, creator); + await createEmployee(database, identifier, fname, lname, department, building, creator); } catch (err: unknown) { console.debug(`Failed to create employee: ${(err as Error).message}`); return fail(400, { diff --git a/src/routes/(dashboard)/create/employee/+page.svelte b/src/routes/(dashboard)/create/employee/+page.svelte index b042278..85973cd 100644 --- a/src/routes/(dashboard)/create/employee/+page.svelte +++ b/src/routes/(dashboard)/create/employee/+page.svelte @@ -32,7 +32,9 @@ function constructEmail(): string { const fname = removeDiacritics($formData.fname).toLowerCase(); const lname = removeDiacritics($formData.lname).toLowerCase(); - const department = removeDiacritics($formData.department).toLowerCase(); + const department = $formData.department + ? removeDiacritics($formData.department).toLowerCase() + : 'none'; return `${fname}.${lname}@${department}.uns.ac.rs`; } @@ -109,8 +111,8 @@ {$formData.department ?? 'Select the department for the employee'} - {#each data.departments as department (department.id)} - + {#each data.departments as { id, name } (id)} + {/each} diff --git a/src/routes/(dashboard)/create/employee/schema.ts b/src/routes/(dashboard)/create/employee/schema.ts index b67927f..5665126 100644 --- a/src/routes/(dashboard)/create/employee/schema.ts +++ b/src/routes/(dashboard)/create/employee/schema.ts @@ -2,14 +2,14 @@ import { z } from 'zod'; import { nameRegExp, nameRegExpMsg } from '$lib/utils/regexp'; export const formSchema = z.object({ - fname: z.string().min(2).max(50).regex(nameRegExp, nameRegExpMsg), - lname: z.string().min(2).max(50).regex(nameRegExp, nameRegExpMsg), + fname: z.string().min(1).max(50).regex(nameRegExp, nameRegExpMsg), + lname: z.string().min(1).max(50).regex(nameRegExp, nameRegExpMsg), identifier: z .string() .refine((value) => z.string().email().safeParse(value).success || /^\d{4}$/.test(value), { message: 'Identifier must be a valid email or a 4-digit number' }), - department: z.string() + department: z.string().optional() }); export type FormSchema = typeof formSchema; diff --git a/src/routes/(dashboard)/create/guest/+page.server.ts b/src/routes/(dashboard)/create/guest/+page.server.ts new file mode 100644 index 0000000..42ec8ab --- /dev/null +++ b/src/routes/(dashboard)/create/guest/+page.server.ts @@ -0,0 +1,97 @@ +import { fail, type Actions } from '@sveltejs/kit'; +import { superValidate } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; + +import { createGuest, getPersons } from '$lib/server/db/person'; +import { getUniversities } from '$lib/server/db/university'; + +import { formSchema } from './schema'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const { database } = locals; + const form = await superValidate(zod(formSchema)); + + const universitiesP = getUniversities(database); + const personsP = getPersons(database, 10, 0, { nonGuestsOnly: true }); + + const universities = await universitiesP; + const persons = await personsP; + + return { + form, + universities, + persons + }; +}; + +export const actions: Actions = { + create: async ({ locals, request }) => { + const { database } = locals; + const form = await superValidate(request, zod(formSchema)); + if (!form.valid) { + return fail(400, { + form, + message: 'Invalid form inputs' + }); + } + + if (locals.session === null || locals.user === null) { + return fail(401, { + form, + message: 'Invalid session' + }); + } + + try { + const { identifier, fname, lname, university, guarantorId } = form.data; + const building = locals.session.building; + const creator = locals.user.username; + await createGuest( + database, + identifier, + fname, + lname, + university, + building, + creator, + guarantorId + ); + } catch (err: unknown) { + console.debug(`Failed to create guest: ${(err as Error).message}`); + return fail(400, { + form, + message: 'Guest already exists' + }); + } + + return { + form, + message: 'Guest created successfully!' + }; + }, + search: async ({ locals, request }) => { + const { database } = locals; + try { + const formData = await request.formData(); + const searchQuery = formData.get('q'); + + // Check if the searchQuery is valid + if (searchQuery === null || searchQuery === undefined || typeof searchQuery !== 'string') { + return fail(400, { + message: 'Invalid search query' + }); + } + + const persons = await getPersons(database, 10, 0, { searchQuery, nonGuestsOnly: true }); + return { + persons + }; + } catch (err: unknown) { + console.debug(`Failed to search: ${(err as Error).message}`); + return fail(500, { + message: 'Failed to search' + }); + } + } +}; diff --git a/src/routes/(dashboard)/create/guest/+page.svelte b/src/routes/(dashboard)/create/guest/+page.svelte new file mode 100644 index 0000000..3072198 --- /dev/null +++ b/src/routes/(dashboard)/create/guest/+page.svelte @@ -0,0 +1,197 @@ + + +
{ + return async ({ update }) => { + await update({ reset: false }); + }; + }} +> + +
+ +
+ + + Create guest + + Create a guest who wants to enter the building for the first time. + + + + + + {#snippet children({ props })} + First name + + {/snippet} + + + + + + {#snippet children({ props })} + Last name + + {/snippet} + + + + + + {#snippet children({ props })} + Index / Email / Last 4 digits from ID + + {/snippet} + + + + + + {#snippet children({ props })} + University + + + {$formData.university ?? 'Select the university for the guest'} + + + {#each data.universities as { id, name } (id)} + + {/each} + + + {/snippet} + + + + + + + {#snippet children({ props })} +
+ Guarantor + + {selectedPerson + ? `${selectedPerson.fname} ${selectedPerson.lname} ${selectedPerson.identifier}` + : 'Select guarantor'} + + + +
+ {/snippet} +
+ + + { + clearTimeout(postTimeout); + postTimeout = setTimeout(() => searchForm?.requestSubmit(), 200); + }} + placeholder="Search guarantor..." + class="h-9" + bind:value={searchInput} + /> + + + {#each persons as { id, fname, lname, identifier } (id)} + {@const label = `${fname} ${lname} (${identifier})`} + { + if ($formData.guarantorId === id) { + $formData.guarantorId = undefined; + } else { + $formData.guarantorId = id; + closeAndFocusTrigger(triggerId); + } + }} + > + {label} + + + {/each} + + + + +
+ +
+ Submit +
+
+
diff --git a/src/routes/(dashboard)/create/guest/schema.ts b/src/routes/(dashboard)/create/guest/schema.ts new file mode 100644 index 0000000..db2501c --- /dev/null +++ b/src/routes/(dashboard)/create/guest/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { nameRegExp, nameRegExpMsg } from '$lib/utils/regexp'; + +export const formSchema = z.object({ + fname: z.string().min(1).max(50).regex(nameRegExp, nameRegExpMsg), + lname: z.string().min(1).max(50).regex(nameRegExp, nameRegExpMsg), + identifier: z.string().min(4), + university: z.string().optional(), + guarantorId: z.number().optional() +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(dashboard)/create/student/+page.server.ts b/src/routes/(dashboard)/create/student/+page.server.ts index 9f559d5..f30694a 100644 --- a/src/routes/(dashboard)/create/student/+page.server.ts +++ b/src/routes/(dashboard)/create/student/+page.server.ts @@ -2,12 +2,11 @@ import { fail, type Actions } from '@sveltejs/kit'; import { superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; -import { createPerson } from '$lib/server/db/person'; +import { createStudent } from '$lib/server/db/person'; import { getDepartments } from '$lib/server/db/department'; import { formSchema } from './schema'; import type { PageServerLoad } from './$types'; -import { Student } from '$lib/types/person'; export const load: PageServerLoad = async ({ locals }) => { const { database } = locals; @@ -40,10 +39,9 @@ export const actions: Actions = { try { const { identifier, fname, lname, department } = form.data; - const type = Student; const building = locals.session.building; const creator = locals.user.username; - await createPerson(database, identifier, type, fname, lname, department, building, creator); + await createStudent(database, identifier, fname, lname, department, building, creator); } catch (err: unknown) { console.debug(`Failed to create student: ${(err as Error).message}`); return fail(400, { diff --git a/src/routes/(dashboard)/create/student/+page.svelte b/src/routes/(dashboard)/create/student/+page.svelte index ac62831..4948911 100644 --- a/src/routes/(dashboard)/create/student/+page.svelte +++ b/src/routes/(dashboard)/create/student/+page.svelte @@ -32,7 +32,7 @@ Create student - Create an student who wants to enter the building for the first time. + Create a student who wants to enter the building for the first time. @@ -72,8 +72,8 @@ {$formData.department ?? 'Select the department for the student'} - {#each data.departments as department (department.id)} - + {#each data.departments as { id, name } (id)} + {/each} diff --git a/src/routes/(dashboard)/create/student/schema.ts b/src/routes/(dashboard)/create/student/schema.ts index d758952..9d93d75 100644 --- a/src/routes/(dashboard)/create/student/schema.ts +++ b/src/routes/(dashboard)/create/student/schema.ts @@ -2,10 +2,10 @@ import { z } from 'zod'; import { indexRegExp, indexRegExpMsg, nameRegExp, nameRegExpMsg } from '$lib/utils/regexp'; export const formSchema = z.object({ - fname: z.string().min(2).max(50).regex(nameRegExp, nameRegExpMsg), - lname: z.string().min(2).max(50).regex(nameRegExp, nameRegExpMsg), + fname: z.string().min(1).max(50).regex(nameRegExp, nameRegExpMsg), + lname: z.string().min(1).max(50).regex(nameRegExp, nameRegExpMsg), identifier: z.string().regex(indexRegExp, indexRegExpMsg), - department: z.string() + department: z.string().optional() }); export type FormSchema = typeof formSchema; diff --git a/src/routes/(dashboard)/data-table-actions.svelte b/src/routes/(dashboard)/data-table-actions.svelte index f13c0d7..58fa024 100644 --- a/src/routes/(dashboard)/data-table-actions.svelte +++ b/src/routes/(dashboard)/data-table-actions.svelte @@ -5,12 +5,17 @@ import { Input } from '$lib/components/ui/input'; import { searchStore } from '$lib/stores/search.svelte'; - let { id, type }: { id: number; type: string } = $props(); + let { + id, + guarantorId + }: { + id: number; + guarantorId: number | null; + } = $props();
- diff --git a/src/routes/(dashboard)/statistics/+page.server.ts b/src/routes/(dashboard)/statistics/+page.server.ts index b9f9cee..4f6dc1c 100644 --- a/src/routes/(dashboard)/statistics/+page.server.ts +++ b/src/routes/(dashboard)/statistics/+page.server.ts @@ -1,19 +1,30 @@ -import { getPersonsCountPerBuilding, getPersonsCountPerDepartment } from '$lib/server/db/person'; +import { + getPersonsCountPerType, + getPersonsCountPerBuilding, + getPersonsCountPerDepartment, + getPersonsCountPerUniversity +} from '$lib/server/db/person'; import { fail, type Actions } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { const { database } = locals; try { - const personsCountP = getPersonsCountPerDepartment(database); const personsInsideCountP = getPersonsCountPerBuilding(database); + const personsCountPerTypeP = getPersonsCountPerType(database); + const personsCountPerDepartmentP = getPersonsCountPerDepartment(database); + const personsCountPerUniversityP = getPersonsCountPerUniversity(database); - const personsCount = await personsCountP; const personsInsideCount = await personsInsideCountP; + const personsCountPerType = await personsCountPerTypeP; + const personsCountPerDepartment = await personsCountPerDepartmentP; + const personsCountPerUniversity = await personsCountPerUniversityP; return { - personsCount, - personsInsideCount + personsInsideCount, + personsCountPerType, + personsCountPerDepartment, + personsCountPerUniversity }; } catch (err: unknown) { console.debug(`Failed to get statistics: ${(err as Error).message}`); @@ -27,15 +38,21 @@ export const actions: Actions = { default: async ({ locals }) => { const { database } = locals; try { - const personsCountP = getPersonsCountPerDepartment(database); const personsInsideCountP = getPersonsCountPerBuilding(database); + const personsCountPerTypeP = getPersonsCountPerType(database); + const personsCountPerDepartmentP = getPersonsCountPerDepartment(database); + const personsCountPerUniversityP = getPersonsCountPerUniversity(database); - const personsCount = await personsCountP; const personsInsideCount = await personsInsideCountP; + const personsCountPerType = await personsCountPerTypeP; + const personsCountPerDepartment = await personsCountPerDepartmentP; + const personsCountPerUniversity = await personsCountPerUniversityP; return { - personsCount, personsInsideCount, + personsCountPerType, + personsCountPerDepartment, + personsCountPerUniversity, message: 'Successfully refreshed statistics' }; } catch (err: unknown) { diff --git a/src/routes/(dashboard)/statistics/+page.svelte b/src/routes/(dashboard)/statistics/+page.svelte index b011b5b..36c1d21 100644 --- a/src/routes/(dashboard)/statistics/+page.svelte +++ b/src/routes/(dashboard)/statistics/+page.svelte @@ -1,8 +1,10 @@ - - - General + + Inside + General + Department + University + + + + Inside Statistics + Check how many people are currently inside. + + + + + + + + + General Statistics - Check how many people are in the system. + Check how many people are in the system per type. - + - + - Inside Statistics - Check how many people are currently inside. + Department Statistics + + Check how many people are in the system per department. + + + + + + + + + + + + + + University Statistics + + Check how many people are in the system per university. + - + diff --git a/src/lib/components/custom/stats/countPerDepartment.svelte b/src/routes/(dashboard)/statistics/countDepartment.svelte similarity index 60% rename from src/lib/components/custom/stats/countPerDepartment.svelte rename to src/routes/(dashboard)/statistics/countDepartment.svelte index 09f6ac3..5561987 100644 --- a/src/lib/components/custom/stats/countPerDepartment.svelte +++ b/src/routes/(dashboard)/statistics/countDepartment.svelte @@ -1,19 +1,18 @@ - {#each allTypes as { type }} + {#each allTypesPerDepartments as { type }} {/each} @@ -90,22 +69,3 @@ {/each}
Department{type}
- - - - - - - - - - - - {#each totalCountPerType as { type, count }} - - - - - {/each} - -
TypeTotal
{type}{count}
diff --git a/src/lib/components/custom/stats/insideCountPerBuilding.svelte b/src/routes/(dashboard)/statistics/countInside.svelte similarity index 82% rename from src/lib/components/custom/stats/insideCountPerBuilding.svelte rename to src/routes/(dashboard)/statistics/countInside.svelte index f623f34..2ac35a4 100644 --- a/src/lib/components/custom/stats/insideCountPerBuilding.svelte +++ b/src/routes/(dashboard)/statistics/countInside.svelte @@ -7,7 +7,7 @@ personsInsideCount: { type: PersonType | null; building: string; - insideCount: number; + count: number; }[]; } = $props(); @@ -19,21 +19,21 @@ ): d is { type: PersonType; building: string; - insideCount: number; + count: number; } => d.type !== null ) .filter((item, index, self) => self.findIndex((other) => other.type === item.type) === index) .map((d) => ({ type: d.type, - insideCount: 0 + count: 0 })) .sort((t1, t2) => t1.type.localeCompare(t2.type)) ); const buildings = $derived.by(() => { const dataMap = personsInsideCount.reduce( - (acc, { building, type, insideCount }) => { + (acc, { building, type, count }) => { if (!acc[building]) acc[building] = {} as Record; - if (type !== null) acc[building][type] = insideCount; + if (type !== null) acc[building][type] = count; return acc; }, {} as Record> @@ -43,9 +43,9 @@ .map(([building, types]) => ({ building, types: [ - ...Object.entries(types).map(([type, insideCount]) => ({ + ...Object.entries(types).map(([type, count]) => ({ type: type as PersonType, - insideCount + count })), ...allTypes ] @@ -71,8 +71,8 @@ {#each buildings as { building, types }} {building} - {#each types as { insideCount }} - {insideCount} + {#each types as { count }} + {count} {/each} {/each} diff --git a/src/routes/(dashboard)/statistics/countType.svelte b/src/routes/(dashboard)/statistics/countType.svelte new file mode 100644 index 0000000..4b1bb89 --- /dev/null +++ b/src/routes/(dashboard)/statistics/countType.svelte @@ -0,0 +1,29 @@ + + + + + + + + + + + {#each personsCountPerType as { type, count }} + + + + + {/each} + +
TypeTotal
{type}{count}
diff --git a/src/routes/(dashboard)/statistics/countUniversity.svelte b/src/routes/(dashboard)/statistics/countUniversity.svelte new file mode 100644 index 0000000..3cf56be --- /dev/null +++ b/src/routes/(dashboard)/statistics/countUniversity.svelte @@ -0,0 +1,71 @@ + + + + + + + {#each allTypesPerUniversity as { type }} + + {/each} + + + + {#each universities as { university, types }} + + + {#each types as { count }} + + {/each} + + {/each} + +
University{type}
{university}{count}
diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 352c418..c9b804b 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -20,6 +20,8 @@ Create Building {:else if $page.url.pathname === '/admin/create/department'} Create Department + {:else if $page.url.pathname === '/admin/create/university'} + Create University {:else} Admin Homepage {/if} diff --git a/src/routes/admin/create/building/schema.ts b/src/routes/admin/create/building/schema.ts index ef3bb0a..0564466 100644 --- a/src/routes/admin/create/building/schema.ts +++ b/src/routes/admin/create/building/schema.ts @@ -1,8 +1,8 @@ -import { uppercaseRegExp, uppercaseRegExpMsg } from '$lib/utils/regexp'; +import { wordRegExp, wordRegExpMsg } from '$lib/utils/regexp'; import { z } from 'zod'; export const formSchema = z.object({ - building: z.string().min(2).max(20).regex(uppercaseRegExp, uppercaseRegExpMsg), + building: z.string().regex(wordRegExp, wordRegExpMsg), secret: z.string().min(32).max(255) }); diff --git a/src/routes/admin/create/department/schema.ts b/src/routes/admin/create/department/schema.ts index 1a9e4fd..b1e5a40 100644 --- a/src/routes/admin/create/department/schema.ts +++ b/src/routes/admin/create/department/schema.ts @@ -1,8 +1,8 @@ -import { uppercaseRegExp, uppercaseRegExpMsg } from '$lib/utils/regexp'; +import { wordRegExp, wordRegExpMsg } from '$lib/utils/regexp'; import { z } from 'zod'; export const formSchema = z.object({ - department: z.string().min(2).max(20).regex(uppercaseRegExp, uppercaseRegExpMsg), + department: z.string().regex(wordRegExp, wordRegExpMsg), secret: z.string().min(32).max(255) }); diff --git a/src/routes/admin/create/university/+page.server.ts b/src/routes/admin/create/university/+page.server.ts new file mode 100644 index 0000000..bc31487 --- /dev/null +++ b/src/routes/admin/create/university/+page.server.ts @@ -0,0 +1,53 @@ +import { fail, type Actions } from '@sveltejs/kit'; +import { superValidate } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; +import { formSchema } from './schema'; +import type { PageServerLoad } from './$types'; +import { createUniversity } from '$lib/server/db/university'; +import { validateSecret } from '$lib/server/secret'; + +export const load: PageServerLoad = async () => { + const form = await superValidate(zod(formSchema)); + + return { + form + }; +}; + +export const actions: Actions = { + default: async ({ locals, request }) => { + const { database } = locals; + const form = await superValidate(request, zod(formSchema)); + if (!form.valid) { + return fail(400, { + form, + message: 'Invalid form inputs' + }); + } + + // Check if the secret is correct + const secretOk = await validateSecret(form.data.secret); + if (!secretOk) { + return fail(401, { + form, + message: 'Invalid secret' + }); + } + + try { + const { university } = form.data; + await createUniversity(database, university); + } catch (err: unknown) { + console.debug(`Failed to create university: ${(err as Error).message}`); + return fail(400, { + form, + message: 'University already exists' + }); + } + + return { + form, + message: 'University created successfully!' + }; + } +}; diff --git a/src/routes/admin/create/university/+page.svelte b/src/routes/admin/create/university/+page.svelte new file mode 100644 index 0000000..1110bd5 --- /dev/null +++ b/src/routes/admin/create/university/+page.svelte @@ -0,0 +1,57 @@ + + + + + + Create university + Enter the name of the new university to create. + + + + + {#snippet children({ props })} + University + + {/snippet} + + + + + + {#snippet children({ props })} + Secret + + {/snippet} + + + + Submit + + + diff --git a/src/routes/admin/create/university/schema.ts b/src/routes/admin/create/university/schema.ts new file mode 100644 index 0000000..982f37e --- /dev/null +++ b/src/routes/admin/create/university/schema.ts @@ -0,0 +1,9 @@ +import { wordRegExp, wordRegExpMsg } from '$lib/utils/regexp'; +import { z } from 'zod'; + +export const formSchema = z.object({ + university: z.string().regex(wordRegExp, wordRegExpMsg), + secret: z.string().min(32).max(255) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/admin/nuke/+page.server.ts b/src/routes/admin/nuke/+page.server.ts index 0c3118f..d93b723 100644 --- a/src/routes/admin/nuke/+page.server.ts +++ b/src/routes/admin/nuke/+page.server.ts @@ -10,8 +10,10 @@ import { getPersonTypes, removePersonsFromBuilding } from '$lib/server/db/person export const load: PageServerLoad = async ({ locals }) => { const { database } = locals; const form = await superValidate(zod(formSchema)); + const buildingsP = getBuildings(database); const personTypesP = getPersonTypes(database); + const buildings = await buildingsP; const personTypes = await personTypesP; diff --git a/src/routes/admin/nuke/schema.ts b/src/routes/admin/nuke/schema.ts index efce236..9c8ee2f 100644 --- a/src/routes/admin/nuke/schema.ts +++ b/src/routes/admin/nuke/schema.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; export const formSchema = z.object({ - building: z.string().min(2).max(50), - personType: z.string().min(2).max(50), + building: z.string(), + personType: z.string(), secret: z.string().min(32).max(255) }); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 6e9a6ec..f0e7d50 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -25,8 +25,6 @@ export const actions: Actions = { default: async (event) => { const { locals, request } = event; const { database } = locals; - const ratelimitMaxAttempts = Number.parseInt(env.RATELIMIT_MAX_ATTEMPTS ?? '5'); - const ratelimitTimeout = Number.parseInt(env.RATELIMIT_TIMEOUT ?? '60'); const form = await superValidate(request, zod(formSchema)); if (!form.valid) { @@ -38,6 +36,8 @@ export const actions: Actions = { try { const { username, password, building } = form.data; + const ratelimitMaxAttempts = Number.parseInt(env.RATELIMIT_MAX_ATTEMPTS ?? '5'); + const ratelimitTimeout = Number.parseInt(env.RATELIMIT_TIMEOUT ?? '60'); // Check if the username exists const { id, passwordHash } = await getUserIdAndPasswordHash(database, username); diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 7c4c592..caec29a 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -94,8 +94,8 @@ {$formData.building ?? 'Select Building'} - {#each data.buildings as building (building.id)} - + {#each data.buildings as { id, name } (id)} + {/each} diff --git a/src/routes/login/schema.ts b/src/routes/login/schema.ts index a8bd312..7b10ff5 100644 --- a/src/routes/login/schema.ts +++ b/src/routes/login/schema.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; export const formSchema = z.object({ username: z.string().regex(indexRegExp, indexRegExpMsg), password: z.string().min(8).max(255), - building: z.string().min(2).max(50) + building: z.string() }); export type FormSchema = typeof formSchema;