diff --git a/client/src/app/dashboard/(application)/education/page.tsx b/client/src/app/dashboard/(application)/education/page.tsx index 3a83738b..ff0aea1a 100644 --- a/client/src/app/dashboard/(application)/education/page.tsx +++ b/client/src/app/dashboard/(application)/education/page.tsx @@ -127,7 +127,7 @@ function EducationForm({ schoolOptions, countryOptions, application }: Education async function onSubmit(values: z.infer): Promise { await updateApplication("education", values) await mutateApplication({ ...application, ...values }) - if (application.university == null) router.push("/dashboard/cv") + if (application.university == null) router.push("/dashboard/travel") } return ( diff --git a/client/src/app/dashboard/(application)/travel-reimbursement/error.tsx b/client/src/app/dashboard/(application)/travel-reimbursement/error.tsx new file mode 100644 index 00000000..5611be6d --- /dev/null +++ b/client/src/app/dashboard/(application)/travel-reimbursement/error.tsx @@ -0,0 +1,3 @@ +"use client" + +export { FormLoadingError as default } from "@/components/dashboard/form-loading-error" diff --git a/client/src/app/dashboard/(application)/travel-reimbursement/layout.tsx b/client/src/app/dashboard/(application)/travel-reimbursement/layout.tsx new file mode 100644 index 00000000..2729f07a --- /dev/null +++ b/client/src/app/dashboard/(application)/travel-reimbursement/layout.tsx @@ -0,0 +1,11 @@ +import type * as React from "react" + +export default function TravelReimbursementFormLayout({ children }: { children: React.ReactNode }) { + return ( + <> +

Travel Reimbursement Form +

+ {children} + + ) +} diff --git a/client/src/app/dashboard/(application)/travel-reimbursement/page.tsx b/client/src/app/dashboard/(application)/travel-reimbursement/page.tsx new file mode 100644 index 00000000..d45f7667 --- /dev/null +++ b/client/src/app/dashboard/(application)/travel-reimbursement/page.tsx @@ -0,0 +1,187 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import * as React from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { MultiSelect } from "@durhack/web-components/ui/multi-select" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@durhack/web-components/ui/form" +import { Input } from "@durhack/web-components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectValueClipper, +} from "@durhack/web-components/ui/select" +import { + FileUpload, + FileUploadDropzoneBasket, + FileUploadDropzoneInput, + FileUploadDropzoneRoot, + FileUploadErrorMessage, + FileUploadFileList, +} from "@durhack/web-components/ui/file-upload" + + +import { FormSkeleton } from "@/components/dashboard/form-skeleton" +import { FormSubmitButton } from "@/components/dashboard/form-submit-button" +import type { Application } from "@/hooks/use-application" +import { useApplicationContext } from "@/hooks/use-application-context" +import { useTravelReimbursementForm } from "@/hooks/use-travel-reimbursement-form-context" +import { isLoaded } from "@/lib/is-loaded" +import { updateApplication } from "@/lib/update-application" + +type TravelReimbursementFormFields = { + + methodOfTravel: string + receiptFiles: File[] +} + +const TravelReimbursementFormSchema = z.object({ + methodoftravel: z + .array( + z.enum(["train", "bus", "private-road-vehicle", "international-transport", "other"]) + ), + receiptFiles: z + .array( + z + .custom((value) => value instanceof File) + .refine((value) => value.size <= 10485760, "Maximum file size is 10MB!") + .refine((value) => { + if ( + ![ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + "application/png", + "application/jpg" + ].includes(value.type) + ) + return false + + const split = value.name.split(".") + const extension = split[split.length - 1] + return ["doc", "docx", "pdf","png", "jpg"].includes(extension) + }, "Please upload a PDF or Word doc or a PNG or JPG image!"), + ) + + }) + + +/** + * This component accepts application via props, rather than via + * useApplicationContext, because it requires the application to already be loaded before being rendered. + */ +function TravelReimbursementForm({ application }: { application: Application }) { + const router = useRouter() + const TravelReimbursementForm = useTravelReimbursementForm() + + const form = useForm>({ + resolver: zodResolver(TravelReimbursementFormSchema), + }) + + async function onSubmit(values: z.infer): Promise { + await updateApplication("travelReimbursement", values) + //await mutateApplication({ ...application, ...values }) + } + + return ( +
+ +
+
+ ( + + Method of travel +
+ + + + + {value === "other" && } +
+ +
+ )} + /> +
+
+ + +
+ ( + + Travel receipts + +

+ Only pdf, doc, docs, png and jpg files are accepted. +

+
+ + + + + + + + + +
+ )} + /> +
+
+ Submit travel reimbursement request +
+
+ + ) +} + +function TravelReimbursementFormSkeleton() { + return +} + +export default function TravelReimbursementFormPage() { + const { application, applicationIsLoading } = useApplicationContext() + + if (!isLoaded(application, applicationIsLoading)) { + return + } + + return +} diff --git a/client/src/app/dashboard/(application)/travel/error.tsx b/client/src/app/dashboard/(application)/travel/error.tsx new file mode 100644 index 00000000..5611be6d --- /dev/null +++ b/client/src/app/dashboard/(application)/travel/error.tsx @@ -0,0 +1,3 @@ +"use client" + +export { FormLoadingError as default } from "@/components/dashboard/form-loading-error" diff --git a/client/src/app/dashboard/(application)/travel/layout.tsx b/client/src/app/dashboard/(application)/travel/layout.tsx new file mode 100644 index 00000000..63b85456 --- /dev/null +++ b/client/src/app/dashboard/(application)/travel/layout.tsx @@ -0,0 +1,10 @@ +import type * as React from "react" + +export default function TravelPageLayout({ children }: { children: React.ReactNode }) { + return ( + <> +

Travel Details

+ {children} + + ) +} diff --git a/client/src/app/dashboard/(application)/travel/page.tsx b/client/src/app/dashboard/(application)/travel/page.tsx new file mode 100644 index 00000000..4dcdfb1e --- /dev/null +++ b/client/src/app/dashboard/(application)/travel/page.tsx @@ -0,0 +1,103 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import * as React from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@durhack/web-components/ui/form" +import { Input } from "@durhack/web-components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectValueClipper, +} from "@durhack/web-components/ui/select" +import { FormSkeleton } from "@/components/dashboard/form-skeleton" +import { FormSubmitButton } from "@/components/dashboard/form-submit-button" +import type { Application } from "@/hooks/use-application" +import { useApplicationContext } from "@/hooks/use-application-context" +import { isLoaded } from "@/lib/is-loaded" +import { updateApplication } from "@/lib/update-application" + +type TravelDetailsFormFields = { + travelOrigin: string +} +const TravelDetailsFormSchema = z.object({ + travelOrigin: z.enum(["prefer-not-to-answer", "durham", "elsewhere-in-the-uk", "abroad"]) + }) +/** + * This component accepts application via props, rather than via + * useApplicationContext, because it requires the application to already be loaded before being rendered. + */ +function TravelDetailsForm({ application }: { application: Application }) { + const router = useRouter() + const { mutateApplication } = useApplicationContext() + + const form = useForm>({ + resolver: zodResolver(TravelDetailsFormSchema), + }) +async function onSubmit(values: z.infer): Promise { + await updateApplication("travel", values) + await mutateApplication({ ...application, ...values }) + //if (application.travelOrigin == null) router.push("/dashboard/cv") + } + return ( +
+ +
+
+ ( + + Where will you be travelling from +
+ + {value === "elsewhere-in-the-uk" && } + {value === "prefer-not-to-answer" &&

+ You will not be able to apply for travel reimbursement.

} +
+ +
+ )} + /> +
+
+
+ Save Progress +
+
+ + ) + } +function TravelDetailsFormSkeleton() { + return +} + +export default function TravelDetailsFormPage() { + const { application, applicationIsLoading } = useApplicationContext() + + if (!isLoaded(application, applicationIsLoading)) { + return + } + + return +} \ No newline at end of file diff --git a/client/src/components/dashboard/sidebar.tsx b/client/src/components/dashboard/sidebar.tsx index 9f21aa03..a7440d1a 100644 --- a/client/src/components/dashboard/sidebar.tsx +++ b/client/src/components/dashboard/sidebar.tsx @@ -19,6 +19,7 @@ const menuItems = [ { id: 4, name: "Contact", link: "/contact" }, { id: 5, name: "Extra", link: "/extra" }, { id: 6, name: "Education", link: "/education" }, + { id: 9, name: "Travel", link: "/travel" }, { id: 7, name: "CV", link: "/cv" }, { id: 8, name: "Submit", link: "/submit" }, ] as const satisfies readonly MenuItem[] diff --git a/client/src/hooks/use-travel-reimbursement-form-context.ts b/client/src/hooks/use-travel-reimbursement-form-context.ts new file mode 100644 index 00000000..ba785410 --- /dev/null +++ b/client/src/hooks/use-travel-reimbursement-form-context.ts @@ -0,0 +1,24 @@ +import type { TravelReimbursementForm } from "@durhack/durhack-common/types/application" +import ModuleError from "module-error" +import useSWR from "swr" + +import { siteConfig } from "@/config/site" + +export type { TravelReimbursementForm } + +async function applicationFetcher(path: string): Promise { + const url = new URL(path, siteConfig.apiUrl) + const response = await fetch(url, { credentials: "include" }) + + if (response.status === 401) + throw new ModuleError("Couldn't fetch user registration details because user is not logged in", { + code: "ERR_UNAUTHENTICATED", + }) + if (!response.ok) throw new Error("Couldn't fetch user registration details for unknown reason") + + return (await response.json()).data as TravelReimbursementForm +} + +export function useTravelReimbursementForm() { + return useSWR("/travel-reimbursement-form", applicationFetcher) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38c221cc..0253d824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@otterhttp/errors': specifier: ^0.3.0 version: 0.3.0 + '@otterhttp/logger': + specifier: ^0.1.1 + version: 0.1.1 '@otterhttp/parsec': specifier: ^0.2.1 version: 0.2.1 @@ -772,6 +775,10 @@ packages: resolution: {integrity: sha512-YnRt6J4NEVBXVBdMaF2btHWE8XmJZ7sJz2ZR8O6UFUctANnceXtRPei31gsFqy/lqUTFrcrKmRcdQV9fzXcfTA==} engines: {node: '>=20.16.0'} + '@otterhttp/logger@0.1.1': + resolution: {integrity: sha512-JOI/N7GLSULS7ErgJH5yZyc3J2yqONZo5Sz5j+E+wAZWAMHJ8CdiNbT76Ai+QBMbfnxhgz9Apt/zFm2TIb+i8g==} + engines: {node: '>=20'} + '@otterhttp/parameters@0.1.0': resolution: {integrity: sha512-yhqvMwsEZNA5iSssRzcjxi+sDUWg1TUyvxe6P5EeMgHFprsLoqh1TJgQO6Vji0wciHgkZduNzKSrlPdjyV7qyA==} engines: {node: '>=20.16.0'} @@ -1687,6 +1694,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1739,6 +1749,9 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3912,6 +3925,11 @@ snapshots: dependencies: ipaddr.js: 2.2.0 + '@otterhttp/logger@0.1.1': + dependencies: + colorette: 2.0.20 + dayjs: 1.11.18 + '@otterhttp/parameters@0.1.0': {} '@otterhttp/parsec@0.2.1': @@ -4848,6 +4866,8 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4896,6 +4916,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + dayjs@1.11.18: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -5157,7 +5179,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1) eslint-plugin-react: 7.37.0(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -5188,7 +5210,7 @@ snapshots: is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node @@ -5206,7 +5228,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 diff --git a/server/package.json b/server/package.json index 84b3f16a..01abd306 100644 --- a/server/package.json +++ b/server/package.json @@ -21,6 +21,7 @@ "@otterhttp/errors": "^0.3.0", "@otterhttp/parsec": "^0.2.1", "@otterhttp/session": "^0.3.1", + "@otterhttp/logger": "^0.1.1", "@prisma/client": "^5.20.0", "corstisol": "^1.0.0", "countries-list": "^3.1.0", diff --git a/server/prisma/migrations/20251001191833_add_travel_origin/migration.sql b/server/prisma/migrations/20251001191833_add_travel_origin/migration.sql new file mode 100644 index 00000000..55e97d32 --- /dev/null +++ b/server/prisma/migrations/20251001191833_add_travel_origin/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserInfo" ADD COLUMN "travel_origin" TEXT; diff --git a/server/prisma/migrations/20251002182133_added_details_from_travel_reimbursement/migration.sql b/server/prisma/migrations/20251002182133_added_details_from_travel_reimbursement/migration.sql new file mode 100644 index 00000000..3d5f09b4 --- /dev/null +++ b/server/prisma/migrations/20251002182133_added_details_from_travel_reimbursement/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "MethodOfTravel" AS ENUM ('train', 'bus', 'private_road_vehicle', 'international_transport', 'other'); + +-- AlterTable +ALTER TABLE "UserInfo" ADD COLUMN "method_of_travel" "MethodOfTravel"[]; + +-- CreateTable +CREATE TABLE "TravelReceipts" ( + "idReceipt" SERIAL NOT NULL, + "user_id" UUID NOT NULL, + "filename" TEXT NOT NULL, + "content_type" TEXT NOT NULL, + "content" BYTEA NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TravelReceipts_pkey" PRIMARY KEY ("idReceipt") +); + +-- AddForeignKey +ALTER TABLE "TravelReceipts" ADD CONSTRAINT "TravelReceipts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("keycloak_user_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20251014190546_added_reimbursement_form/migration.sql b/server/prisma/migrations/20251014190546_added_reimbursement_form/migration.sql new file mode 100644 index 00000000..4680b387 --- /dev/null +++ b/server/prisma/migrations/20251014190546_added_reimbursement_form/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `method_of_travel` on the `UserInfo` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "UserInfo" DROP COLUMN "method_of_travel"; + +-- CreateTable +CREATE TABLE "ReimbursementForm" ( + "user_id" UUID NOT NULL, + "idRequest" SERIAL NOT NULL, + "method_of_travel" "MethodOfTravel"[], + "id_receipt" INTEGER NOT NULL, + + CONSTRAINT "ReimbursementForm_pkey" PRIMARY KEY ("idRequest") +); + +-- AddForeignKey +ALTER TABLE "ReimbursementForm" ADD CONSTRAINT "ReimbursementForm_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("keycloak_user_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReimbursementForm" ADD CONSTRAINT "ReimbursementForm_id_receipt_fkey" FOREIGN KEY ("id_receipt") REFERENCES "TravelReceipts"("idReceipt") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20251027202939_change_of_variable_map/migration.sql b/server/prisma/migrations/20251027202939_change_of_variable_map/migration.sql new file mode 100644 index 00000000..7974d473 --- /dev/null +++ b/server/prisma/migrations/20251027202939_change_of_variable_map/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [private_road_vehicle,international_transport] on the enum `MethodOfTravel` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "MethodOfTravel_new" AS ENUM ('train', 'bus', 'private-road-vehicle', 'international-transport', 'other'); +ALTER TABLE "ReimbursementForm" ALTER COLUMN "method_of_travel" TYPE "MethodOfTravel_new"[] USING ("method_of_travel"::text::"MethodOfTravel_new"[]); +ALTER TYPE "MethodOfTravel" RENAME TO "MethodOfTravel_old"; +ALTER TYPE "MethodOfTravel_new" RENAME TO "MethodOfTravel"; +DROP TYPE "MethodOfTravel_old"; +COMMIT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 0ea8d237..e9d6b838 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -5,149 +5,180 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" - previewFeatures = ["typedSql"] + provider = "prisma-client-js" + previewFeatures = ["typedSql"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } model Interest { - id Int @id @default(autoincrement()) - firstNames String @map("first_names") @db.VarChar(256) - lastNames String @map("last_names") @db.VarChar(256) - email String @unique @db.VarChar(256) - year Int @default(2025) + id Int @id @default(autoincrement()) + firstNames String @map("first_names") @db.VarChar(256) + lastNames String @map("last_names") @db.VarChar(256) + email String @unique @db.VarChar(256) + year Int @default(2025) } model User { - keycloakUserId String @id @map("keycloak_user_id") @db.Uuid - tokenSet TokenSet? - sessions SessionRecord[] - userCv UserCV? - userInfo UserInfo? - userFlags UserFlag[] - userConsents UserConsent[] + keycloakUserId String @id @map("keycloak_user_id") @db.Uuid + tokenSet TokenSet? + sessions SessionRecord[] + userCv UserCV? + userInfo UserInfo? + userFlags UserFlag[] + userConsents UserConsent[] + travelReceipts TravelReceipts[] + ReimbursementForm ReimbursementForm[] } model UserCV { - userId String @id() @map("user_id") @db.Uuid() - user User @relation(fields: [userId], references: [keycloakUserId]) - filename String - contentType String @map("content_type") - content Bytes + userId String @id() @map("user_id") @db.Uuid() + user User @relation(fields: [userId], references: [keycloakUserId]) + filename String + contentType String @map("content_type") + content Bytes - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @updatedAt @map("updated_at") } model UserInfo { - userId String @id @map("user_id") @db.Uuid - user User @relation(fields: [userId], references: [keycloakUserId]) - - applicationStatus UserApplicationStatus @default(unsubmitted) @map("application_status") - applicationSubmittedAt DateTime? @map("application_submitted_at") - applicationAcceptedAt DateTime? @map("application_accepted_at") - applicationStatusUpdatedAt DateTime? @map("application_status_updated_at") - - cvUploadChoice CvUploadChoice @default(indeterminate) @map("cv_upload_choice") - age Int? @db.SmallInt - university String? @db.VarChar(50) - graduationYear Int? @map("graduation_year") - levelOfStudy String? @map("level_of_study") @db.VarChar(50) - countryOfResidence String? @map("country_of_residence") @db.Char(3) - tShirtSize String? @map("tshirt_size") @db.Char(3) - gender Gender? - ethnicity Ethnicity? - hackathonExperience HackathonExperience? @map("hackathon_experience") - accessRequirements String? @map("access_requirements") @db.Text() - - updatedAt DateTime @updatedAt @map("updated_at") + userId String @id @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [keycloakUserId]) + + applicationStatus UserApplicationStatus @default(unsubmitted) @map("application_status") + applicationSubmittedAt DateTime? @map("application_submitted_at") + applicationAcceptedAt DateTime? @map("application_accepted_at") + applicationStatusUpdatedAt DateTime? @map("application_status_updated_at") + + cvUploadChoice CvUploadChoice @default(indeterminate) @map("cv_upload_choice") + age Int? @db.SmallInt + university String? @db.VarChar(50) + graduationYear Int? @map("graduation_year") + levelOfStudy String? @map("level_of_study") @db.VarChar(50) + countryOfResidence String? @map("country_of_residence") @db.Char(3) + tShirtSize String? @map("tshirt_size") @db.Char(3) + gender Gender? + ethnicity Ethnicity? + hackathonExperience HackathonExperience? @map("hackathon_experience") + accessRequirements String? @map("access_requirements") @db.Text() + travelOrigin String? @map("travel_origin") + updatedAt DateTime @updatedAt @map("updated_at") } model UserFlag { - userId String @map("user_id") @db.Uuid - user User @relation(fields: [userId], references: [keycloakUserId]) - flagName String @map("flag_name") + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [keycloakUserId]) + flagName String @map("flag_name") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") - @@id(fields: [userId, flagName], name: "id", map: "user_flag:user_id_and_flag_name") - @@index(fields: [flagName]) + @@id(fields: [userId, flagName], name: "id", map: "user_flag:user_id_and_flag_name") + @@index(fields: [flagName]) } model UserConsent { - userId String @map("user_id") @db.Uuid - user User @relation(fields: [userId], references: [keycloakUserId]) - consentName String @map("consent_name") - choice Boolean + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [keycloakUserId]) + consentName String @map("consent_name") + choice Boolean - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") - @@id(fields: [userId, consentName], name: "id", map: "user_consent:user_id_and_consent_name") + @@id(fields: [userId, consentName], name: "id", map: "user_consent:user_id_and_consent_name") } model TokenSet { - userId String @id @map("user_id") @db.Uuid - user User @relation(fields: [userId], references: [keycloakUserId]) - - tokenType String? @map("token_type") - accessToken String? @map("access_token") - idToken String? @map("id_token") - refreshToken String? @map("refresh_token") - scope String? - accessExpiry DateTime? @map("access_expiry") @db.Timestamp(0) - sessionState String? @map("session_state") + userId String @id @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [keycloakUserId]) + + tokenType String? @map("token_type") + accessToken String? @map("access_token") + idToken String? @map("id_token") + refreshToken String? @map("refresh_token") + scope String? + accessExpiry DateTime? @map("access_expiry") @db.Timestamp(0) + sessionState String? @map("session_state") } model SessionRecord { - sessionRecordId String @id @map("session_record_id") - userId String? @map("user_id") @db.Uuid - user User? @relation(fields: [userId], references: [keycloakUserId]) - data Json - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([userId]) + sessionRecordId String @id @map("session_record_id") + userId String? @map("user_id") @db.Uuid + user User? @relation(fields: [userId], references: [keycloakUserId]) + data Json + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId]) } enum UserApplicationStatus { - unsubmitted - submitted - accepted - waitingList @map("waiting_list") + unsubmitted + submitted + accepted + waitingList @map("waiting_list") } enum CvUploadChoice { - indeterminate - upload - remind - noUpload @map("no_upload") + indeterminate + upload + remind + noUpload @map("no_upload") } enum Gender { - male - female - nonBinary @map("non_binary") - other - preferNotToAnswer @map("prefer_not_to_answer") + male + female + nonBinary @map("non_binary") + other + preferNotToAnswer @map("prefer_not_to_answer") } enum Ethnicity { - american // "American Indian or Alaskan Native" - asian // "Asian / Pacific Islander" - black // "Black or African American" - hispanic //"Hispanic" - white // "White / Caucasian" - other // "Multiple ethnicity / Other" - preferNotToAnswer @map("prefer_not_to_answer") // "Prefer not to answer" + american // "American Indian or Alaskan Native" + asian // "Asian / Pacific Islander" + black // "Black or African American" + hispanic //"Hispanic" + white // "White / Caucasian" + other // "Multiple ethnicity / Other" + preferNotToAnswer @map("prefer_not_to_answer") // "Prefer not to answer" } enum HackathonExperience { - zero // "hacka-novice" - upToTwo @map("up_to_two") // "hack-tastic tourist" - threeToSeven @map("three_to_seven") // "hack wizard" - eightOrMore @map("eight_or_more") // "hackathon guru" + zero // "hacka-novice" + upToTwo @map("up_to_two") // "hack-tastic tourist" + threeToSeven @map("three_to_seven") // "hack wizard" + eightOrMore @map("eight_or_more") // "hackathon guru" +} + +enum MethodOfTravel { + train + bus + privateRoadVehicle @map("private-road-vehicle") + internationalTransport @map("international-transport") + other +} + +model TravelReceipts { + idReceipt Int @id @default(autoincrement()) + userId String @map("user_id") @db.Uuid() + user User @relation(fields: [userId], references: [keycloakUserId]) + filename String + contentType String @map("content_type") + content Bytes + + updatedAt DateTime @updatedAt @map("updated_at") + ReimbursementForm ReimbursementForm[] +} + +model ReimbursementForm { + userId String @map("user_id") @db.Uuid() + user User @relation(fields: [userId], references: [keycloakUserId]) + idRequest Int @id @default(autoincrement()) + methodOfTravel MethodOfTravel[] @map("method_of_travel") + idReceipt Int @map("id_receipt") + receipt TravelReceipts @relation(fields: [idReceipt], references: [idReceipt]) } diff --git a/server/src/main.ts b/server/src/main.ts index 379a1401..4347a13e 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -8,6 +8,8 @@ import { Response } from "@/response" import { routesApp } from "@/routes" import { apiErrorHandler } from "@/routes/error-handling" +import {logger} from "@otterhttp/logger" + const app = new App({ onError: apiErrorHandler, settings: { @@ -24,6 +26,13 @@ app credentials: true, }), ) + .use( + logger({ + methods: ['GET', 'POST', 'PATCH'], + timestamp: { format: 'HH:mm:ss' }, + output: { callback: console.log, color: false } + }) + ) .use(routesApp) const server = createServer({ diff --git a/server/src/routes/application/application-handlers.ts b/server/src/routes/application/application-handlers.ts index 5a2b3b44..2e82ed79 100644 --- a/server/src/routes/application/application-handlers.ts +++ b/server/src/routes/application/application-handlers.ts @@ -17,6 +17,7 @@ import { adaptHackathonExperienceFromDatabase, adaptHackathonExperienceToDatabase, } from "@/database/adapt-hackathon-experience" +//import {adaptTravelOriginFromDatabse, adaptTravelOriginToDatabase} from "@/database/adapt-travel-origin" import { onlyKnownUsers } from "@/decorators/authorise" import { json, multipartFormData } from "@/lib/body-parsers" import { type KeycloakUserInfo, getKeycloakAdminClient } from "@/lib/keycloak-client" @@ -80,6 +81,13 @@ const educationFormSchema = z.object({ countryOfResidence: z.string().iso3(), }) +const travelFormSchema = z.object({ + travelOrigin: z.enum(["prefer-not-to-answer", "durham", "elsewhere-in-the-uk", "abroad"],{ + message: "Please provide your travel origin.", +}) + //.transform(adaptTravelOriginToDatabase), +}) + const extraDetailsFormSchema = z.object({ tShirtSize: z.enum(["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "prefer-not-to-answer"], { message: "Please select a t-shirt size.", @@ -191,6 +199,7 @@ class ApplicationHandlers { graduationYear: userInfo?.graduationYear ?? null, levelOfStudy: (userInfo?.levelOfStudy as Application["levelOfStudy"] | null | undefined) ?? null, disciplinesOfStudy: disciplinesOfStudy, + travelOrigin: (userInfo?.travelOrigin as Application["travelOrigin"] | null | undefined) ?? null, tShirtSize: (userInfo?.tShirtSize?.trimEnd() as Application["tShirtSize"] | null | undefined) ?? null, dietaryRequirements: dietaryRequirements, accessRequirements: userInfo?.accessRequirements ?? null, @@ -297,6 +306,41 @@ class ApplicationHandlers { } } +@onlyKnownUsers() + patchTravel(): Middleware { + return async (request, response) => { + assert(request.user) + assert(request.userProfile) + + const body = await json(request, response) + const payload = travelFormSchema.parse(body) + + const attributes: Record = { + travelOrigin:payload.travelOrigin, + } + + const adminClient = await getKeycloakAdminClient() + const userProfile = await adminClient.users.findOne({ id: request.user.keycloakUserId }) + assert(userProfile) + + const prismaUserInfo = { + travelOrigin: payload.travelOrigin, + } + + await prisma.user.update({ + where: { keycloakUserId: request.user.keycloakUserId }, + data: { + userInfo: { + update: prismaUserInfo, + }, + }, + }) + + response.sendStatus(200) + } + } + + @onlyKnownUsers() patchExtraDetails(): Middleware { return async (request, response) => { @@ -520,7 +564,9 @@ class ApplicationHandlers { if (application.graduationYear == null) { errors.push(new Error("'Education' section has not been completed")) } - + if (application.travelOrigin == null) { + errors.push(new Error("'Travel' section has not been completed")) + } if (application.cvUploadChoice === "indeterminate") { errors.push(new Error("'CV' section has not been completed")) } diff --git a/server/src/routes/application/index.ts b/server/src/routes/application/index.ts index abe6c739..ef751124 100644 --- a/server/src/routes/application/index.ts +++ b/server/src/routes/application/index.ts @@ -56,6 +56,13 @@ applicationApp .all(methodNotAllowed(["GET"])) .get(getApplicationCountryOptions()) +applicationApp + .route("/travel") + //.all(methodNotAllowed(["PATCH"])) + .all(authenticate()) + .patch(applicationHandlers.patchTravel()) + .all(forbiddenOrUnauthorised()) + applicationApp .route("/cv") .all(methodNotAllowed(["PATCH"])) diff --git a/server/src/routes/error-handling.ts b/server/src/routes/error-handling.ts index 8eb9647a..2918d309 100644 --- a/server/src/routes/error-handling.ts +++ b/server/src/routes/error-handling.ts @@ -8,6 +8,8 @@ import { sendHttpErrorResponse, sendZodErrorResponse } from "@/lib/response" import type { Request, Response } from "@/types" export function apiErrorHandler(error: Error, _request: Request, response: Response, next: NextFunction) { + console.log(error) + if (response.headersSent) { return next(error) } diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 1f41822f..a4531e01 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -9,6 +9,7 @@ import { registerInterestApp } from "@/routes/interest" import { profileApp } from "@/routes/profile" import { userApp } from "@/routes/user" import type { Request, Response } from "@/types" +import { reimbursementFormApp } from "./travel-reimbursement-form" export const routesApp = new App() @@ -26,3 +27,4 @@ routesApp.use("/user", userApp) routesApp.use("/application", applicationApp) routesApp.use("/applications", applicationsApp) routesApp.use("/profile/:userId", profileApp) +routesApp.use("/travel-reimbursement-form", reimbursementFormApp) \ No newline at end of file diff --git a/server/src/routes/travel-reimbursement-form/index.ts b/server/src/routes/travel-reimbursement-form/index.ts new file mode 100644 index 00000000..f8025ee4 --- /dev/null +++ b/server/src/routes/travel-reimbursement-form/index.ts @@ -0,0 +1,19 @@ +import { App } from "@otterhttp/app" + +import { authenticate } from "@/middleware/authenticate" +import { forbiddenOrUnauthorised } from "@/middleware/forbidden-or-unauthorised" +import { methodNotAllowed } from "@/middleware/method-not-allowed" +import type { Request, Response } from "@/types" + +import { travelReimbursementFormHandlers } from "./travel-reimbursement-handlers" + +export const reimbursementFormApp = new App() + +reimbursementFormApp + .route("/") + .all(methodNotAllowed(["GET"])) + .all(authenticate()) + .get(travelReimbursementFormHandlers.getApplication()) + .all(forbiddenOrUnauthorised()) + + diff --git a/server/src/routes/travel-reimbursement-form/travel-reimbursement-handlers.ts b/server/src/routes/travel-reimbursement-form/travel-reimbursement-handlers.ts new file mode 100644 index 00000000..cc08b76c --- /dev/null +++ b/server/src/routes/travel-reimbursement-form/travel-reimbursement-handlers.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict" +import { parse as parsePath } from "node:path/posix" +import { ClientError, HttpStatus } from "@otterhttp/errors" +import type { ContentType, ParsedFormFieldFile } from "@otterhttp/parsec" +import { fileTypeFromBuffer } from "file-type" +import { z } from "zod" +import type { Application, DietaryRequirement, TravelReimbursementForm } from "@durhack/durhack-common/types/application" +import { mailgunConfig } from "@/config" +import { prisma } from "@/database" +import { onlyKnownUsers } from "@/decorators/authorise" +import { json, multipartFormData } from "@/lib/body-parsers" +import { type KeycloakUserInfo, getKeycloakAdminClient } from "@/lib/keycloak-client" +import { mailgunClient } from "@/lib/mailgun" +import type { Middleware, Request } from "@/types" +import "@/lib/zod-phone-extension" +import "@/lib/zod-iso3-extension" + + + + +const travelReimbursementFormSchema = z.object({ + methodOfTravel: z.array( + z.enum(["bus", "train", "private-road-vehicle", "international-transport", "other"], { + message: "Please select a method of travel", + })), + //.transform(adaptmethodOfTravelToDatabase), + receiptFiles: z + .object({ + type: z.literal("field-file-list"), + files: z.array(z.custom()) + //.length(1), + }) + //.optional(), +}) + +class TravelReimbursementFormHandlers { + private async loadApplication(request: Request): (Promise ) { + assert(request.userProfile) + const userId = request.userProfile.sub + assert(userId) + const travelReimbursementForm = await prisma.reimbursementForm.findFirst({ + where: { userId: request.userProfile.sub} + }) + return travelReimbursementForm as any + } + + @onlyKnownUsers() + getApplication(): Middleware { + return async (request, response) => { + assert(request.user) + + const payload = await this.loadApplication(request) + + response.status(200) + response.json({ data: payload }) + } + } +} +const travelReimbursementFormHandlers = new TravelReimbursementFormHandlers() +export { travelReimbursementFormHandlers } \ No newline at end of file