From adfb0d2e344446a444873454a67721b6161b4ce9 Mon Sep 17 00:00:00 2001 From: Hieu Nguyen <76720778+hieunguyent12@users.noreply.github.com> Date: Wed, 3 Sep 2025 22:53:51 -0400 Subject: [PATCH 01/24] feat: form autosaving, submission, and tweaks to form fields (#98) * feat: form autosaving, submission, and tweaks to form fields * feat: storage interface + r2 implementation * chore: added error boundary for application forms * chore: added success and error submission message and some optimizations * chore: added regex validation and invalid application state * chore: removed resume_url column * chore: removed useless env variables * chore: refactored application submission handlers, services and repository * chore: changed submission status for db, updated loading state and error response for submission * chore: minor changes and created migration to update `saved_at` column * chore: cleanup and small changes * chore: change save delay to 3 seconnds instead of 1 * chore: removed testing code * chore: added requested changes from alex * chore: refactor frontend * chore: ensure application is published * refactor(applications): move application open function to event service and shortened * fix(portal): upcoming and application past button states and card states * fix(roles): add applicant role AFTER submission * refactor(application): rewrote some response values for application json --------- Co-authored-by: AlexanderWangY --- apps/api/.air.toml | 1 + apps/api/cmd/api/main.go | 11 +- apps/api/go.mod | 19 + apps/api/go.sum | 38 ++ apps/api/internal/api/api.go | 8 + apps/api/internal/api/handlers/application.go | 237 ++++++++ apps/api/internal/api/handlers/handlers.go | 12 +- apps/api/internal/config/config.go | 17 +- .../20250621222955_create_applications.sql | 1 + ...0250825013123_remove_resume_url_column.sql | 9 + ...50825033231_update_application_trigger.sql | 27 + apps/api/internal/db/queries/applications.sql | 3 +- .../api/internal/db/repository/application.go | 112 ++++ apps/api/internal/db/sqlc/applications.sql.go | 15 +- apps/api/internal/db/sqlc/models.go | 1 - apps/api/internal/ptr/uuid.go | 8 + apps/api/internal/services/application.go | 162 +++++ apps/api/internal/services/events.go | 19 + apps/api/internal/storage/r2.go | 97 +++ apps/api/internal/storage/storage.go | 10 + apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 28 + .../components/Form/fields/ComboBoxField.tsx | 2 +- .../components/Form/fields/SubmitButton.tsx | 18 +- .../components/Form/fields/UploadField.tsx | 2 +- .../src/components/ui/FileField/FileField.tsx | 15 +- .../src/components/ui/FileField/FileInput.tsx | 71 ++- .../components/ui/MultiSelect/MultiSelect.tsx | 7 +- .../components/ApplicationForm.tsx | 343 ++++------- .../stories/ApplicationForm.stories.tsx | 28 - .../Application/hooks/useApplication.ts | 15 + .../src/features/Event/applicationStatus.ts | 10 + .../features/Event/components/EventButton.tsx | 6 + apps/web/src/features/Event/utils/mapper.ts | 17 +- apps/web/src/features/FormBuilder/build.tsx | 568 ++++++++++++------ .../FormBuilder/questions/checkbox.ts | 14 +- .../FormBuilder/questions/multiselect.ts | 10 +- .../features/FormBuilder/questions/number.ts | 11 +- .../FormBuilder/questions/shortAnswer.ts | 19 + .../features/FormBuilder/questions/upload.ts | 29 +- .../stories/applicationFormExample.json | 226 +------ apps/web/src/forms/application.json | 370 ++++++++++++ apps/web/src/index.css | 8 + apps/web/src/lib/toast/toast.tsx | 4 +- apps/web/src/routes/application.tsx | 24 - .../routes/events/$eventId/application.tsx | 32 +- apps/web/src/theme.css | 3 +- 47 files changed, 1896 insertions(+), 793 deletions(-) create mode 100644 apps/api/internal/api/handlers/application.go create mode 100644 apps/api/internal/db/migrations/20250825013123_remove_resume_url_column.sql create mode 100644 apps/api/internal/db/migrations/20250825033231_update_application_trigger.sql create mode 100644 apps/api/internal/db/repository/application.go create mode 100644 apps/api/internal/ptr/uuid.go create mode 100644 apps/api/internal/services/application.go create mode 100644 apps/api/internal/storage/r2.go create mode 100644 apps/api/internal/storage/storage.go delete mode 100644 apps/web/src/features/Application/components/stories/ApplicationForm.stories.tsx create mode 100644 apps/web/src/features/Application/hooks/useApplication.ts create mode 100644 apps/web/src/forms/application.json delete mode 100644 apps/web/src/routes/application.tsx diff --git a/apps/api/.air.toml b/apps/api/.air.toml index 3a1e80bc..13fc3367 100644 --- a/apps/api/.air.toml +++ b/apps/api/.air.toml @@ -9,6 +9,7 @@ testdata_dir = "testdata" exclude_regex = ["_test.go"] include_ext = ["go", "tpl", "tmpl", "html"] log = "build-errors.log" + poll = true [color] app = "" diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 98131d6a..a4aabd34 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "github.com/swamphacks/core/apps/api/internal/db/repository" "github.com/swamphacks/core/apps/api/internal/logger" "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/storage" ) func main() { @@ -49,21 +50,29 @@ func main() { // Create new middleware injectable mw := middleware.NewMiddleware(database, logger, cfg) + // Initialize storage clients + r2Client, err := storage.NewR2Client(cfg.CF.AccountID, cfg.CF.AccessKeyId, cfg.CF.AccessKeySecret, logger) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to create R2 client") + } + // Injections into repositories userRepo := repository.NewUserRepository(database) accountRepo := repository.NewAccountRespository(database) sessionRepo := repository.NewSessionRepository(database) eventInterestRepo := repository.NewEventInterestRepository(database) eventRepo := repository.NewEventRespository(database) + applicationRepo := repository.NewApplicationRepository(database) // Injections into services authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth) eventInterestService := services.NewEventInterestService(eventInterestRepo, logger) eventService := services.NewEventService(eventRepo, userRepo, logger) emailService := services.NewEmailService(taskQueueClient, logger) + applicationService := services.NewApplicationService(applicationRepo, eventService, txm, r2Client, &cfg.CoreBuckets, logger) // Injections into handlers - apiHandlers := handlers.NewHandlers(authService, eventInterestService, eventService, emailService, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, eventInterestService, eventService, emailService, applicationService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/go.mod b/apps/api/go.mod index 4080dd4b..a92e0938 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -15,6 +15,25 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.3 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.87.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.28.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.37.1 // indirect + github.com/aws/smithy-go v1.22.5 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index bcd96064..9242c574 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -1,3 +1,41 @@ +github.com/aws/aws-sdk-go-v2 v1.38.0 h1:UCRQ5mlqcFk9HJDIqENSLR3wiG1VTWlyUfLDEvY7RxU= +github.com/aws/aws-sdk-go-v2 v1.38.0/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg= +github.com/aws/aws-sdk-go-v2/config v1.31.1 h1:PSQn4ObaQLaHl6qjs+XYH2pkxyHzZlk1GgQDrKlRJ7I= +github.com/aws/aws-sdk-go-v2/config v1.31.1/go.mod h1:3UA8Gj+2nzpV8WBUF0b19onBfz0YMXDQyGEW0Ru1ntI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.5 h1:DATc1xnpHUV8VgvtnVQul+zuCwK6vz7gtkbKEUZcuNI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.5/go.mod h1:y7aigZzjm1jUZuCgOrlBng+VJrKkknY2Cl0JWxG7vHU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.3 h1:GicIdnekoJsjq9wqnvyi2elW6CGMSYKhdozE7/Svh78= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.3/go.mod h1:R7BIi6WNC5mc1kfRM7XM/VHC3uRWkjc396sfabq4iOo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.5 h1:WTNSeU/4f/vevwK7zwEEjlX27LPZB1IwyjVAh+Q74iQ= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.5/go.mod h1:O84Dxp02jFDHRDbziaCRqMbe12+o+qih3ZD6Dio+1v0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.3 h1:o9RnO+YZ4X+kt5Z7Nvcishlz0nksIt2PIzDglLMP0vA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.3/go.mod h1:+6aLJzOG1fvMOyzIySYjOFjcguGvVRL68R+uoRencN4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.3 h1:joyyUFhiTQQmVK6ImzNU9TQSNRNeD9kOklqTzyk5v6s= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.3/go.mod h1:+vNIyZQP3b3B1tSLI0lxvrU9cfM7gpdRXMFfm67ZcPc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.3 h1:ZV2XK2L3HBq9sCKQiQ/MdhZJppH/rH0vddEAamsHUIs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.3/go.mod h1:b9F9tk2HdHpbf3xbN7rUZcfmJI26N6NcJu/8OsBFI/0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.3 h1:3ZKmesYBaFX33czDl6mbrcHb6jeheg6LqjJhQdefhsY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.3/go.mod h1:7ryVb78GLCnjq7cw45N6oUb9REl7/vNUwjvIqC5UgdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.3 h1:ieRzyHXypu5ByllM7Sp4hC5f/1Fy5wqxqY0yB85hC7s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.3/go.mod h1:O5ROz8jHiOAKAwx179v+7sHMhfobFVi6nZt8DEyiYoM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.3 h1:SE/e52dq9a05RuxzLcjT+S5ZpQobj3ie3UTaSf2NnZc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.3/go.mod h1:zkpvBTsR020VVr8TOrwK2TrUW9pOir28sH5ECHpnAfo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.0 h1:egoDf+Geuuntmw79Mz6mk9gGmELCPzg5PFEABOHB+6Y= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.0/go.mod h1:t9MDi29H+HDbkolTSQtbI0HP9DemAWQzUjmWC7LGMnE= +github.com/aws/aws-sdk-go-v2/service/sso v1.28.1 h1:YfsU8hHGvVT+c6Q8MUs8haDbFQajAImrB7yZ9XnPcBY= +github.com/aws/aws-sdk-go-v2/service/sso v1.28.1/go.mod h1:iS5OmxEcN4QIPXARGhavH7S8kETNL11kym6jhoS7IUQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.1 h1:b4REsk5C0hooowAPmV8fS2haHb+HCyb5FKSKOZRBBfU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.1/go.mod h1:59qHWaY5B+Rs7HGTuVGaC32m0rdpQ68N8QCN3khYiqs= +github.com/aws/aws-sdk-go-v2/service/sts v1.37.1 h1:ssCHKyNJqTnqRH4Vlf+jI0brtGQYBvzWwnATsOMk1mk= +github.com/aws/aws-sdk-go-v2/service/sts v1.37.1/go.mod h1:JdeBDPgpJfuS6rU/hNglmOigKhyEZtBmbraLE4GK1J8= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index aa63701c..a17a0926 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -87,6 +87,14 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.With(mw.Auth.RequireAuth).Get("/role", api.Handlers.Event.GetEventRole) r.Get("/", api.Handlers.Event.GetEventByID) r.Post("/interest", api.Handlers.EventInterest.AddEmailToEvent) + + r.Route("/application", func(r chi.Router) { + r.Use(mw.Auth.RequireAuth) + + r.Get("/", api.Handlers.Application.GetApplicationByUserAndEventID) + r.Post("/submit", api.Handlers.Application.SubmitApplication) + r.Post("/save", api.Handlers.Application.SaveApplication) + }) }) }) diff --git a/apps/api/internal/api/handlers/application.go b/apps/api/internal/api/handlers/application.go new file mode 100644 index 00000000..8b7ddc92 --- /dev/null +++ b/apps/api/internal/api/handlers/application.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/ctxutils" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" + "github.com/swamphacks/core/apps/api/internal/services" +) + +type ApplicationHandler struct { + appService *services.ApplicationService +} + +func NewApplicationHandler(appService *services.ApplicationService) *ApplicationHandler { + return &ApplicationHandler{ + appService: appService, + } +} + +func (h *ApplicationHandler) GetApplicationByUserAndEventID(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + userId := ctxutils.GetUserIdFromCtx(r.Context()) + + if userId == nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "invalid user id")) + return + } + + params := sqlc.GetApplicationByUserAndEventIDParams{ + UserID: *userId, + EventID: eventId, + } + + application, err := h.appService.GetApplicationByUserAndEventID(r.Context(), params) + if err != nil { + if err == repository.ErrApplicationNotFound { + params := sqlc.CreateApplicationParams{ + UserID: *userId, + EventID: eventId, + } + + newApplication, err := h.appService.CreateApplication(r.Context(), params) + + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("create_application_error", "can't create application")) + return + } + + if newApplication == nil { + res.SendError(w, http.StatusBadRequest, res.NewError("create_application_error", "can't create application")) + return + } + + res.Send(w, http.StatusOK, newApplication) + return + } + + if err == services.ErrApplicationUnavailable { + res.SendError(w, http.StatusBadRequest, res.NewError("get_application_error", "the application is unavailable")) + return + } + + res.SendError(w, http.StatusBadRequest, res.NewError("get_application_error", "error retrieving application")) + return + } + + // If the application status is not "started", then it means the user has submitted the application + if application.Status.ApplicationStatus != sqlc.ApplicationStatusStarted { + res.Send(w, http.StatusOK, map[string]any{"submitted": true}) + return + } + + res.Send(w, http.StatusOK, application) +} + +func (h *ApplicationHandler) SubmitApplication(w http.ResponseWriter, r *http.Request) { + // Parse multipart form (10 MB max memory) + err := r.ParseMultipartForm(10 << 20) + if err != nil { + http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest) + return + } + + var submission services.ApplicationSubmissionFields + + // Map form values + submission.FirstName = r.FormValue("firstName") + submission.LastName = r.FormValue("lastName") + + if ageStr := r.FormValue("age"); ageStr != "" { + if age, err := strconv.Atoi(ageStr); err == nil { + submission.Age = age + } + } + + submission.Phone = r.FormValue("phone") + submission.PreferredEmail = r.FormValue("preferredEmail") + submission.UniversityEmail = r.FormValue("universityEmail") + submission.Linkedin = r.FormValue("linkedin") + submission.Github = r.FormValue("github") + + if ageCertStr := r.FormValue("ageCertification"); ageCertStr != "" { + submission.AgeCertification = (ageCertStr == "true" || ageCertStr == "1") + } + + submission.School = r.FormValue("school") + submission.Level = r.FormValue("level") + submission.Year = r.FormValue("year") + submission.GraduationYear = r.FormValue("graduationYear") + submission.Majors = r.FormValue("majors") + submission.Minors = r.FormValue("minors") + submission.Experience = r.FormValue("experience") + submission.ProjectExperience = r.FormValue("projectExperience") + submission.ShirtSize = r.FormValue("shirtSize") + submission.Essay1 = r.FormValue("essay1") + submission.Essay2 = r.FormValue("essay2") + submission.Referral = r.FormValue("referral") + submission.PictureConsent = r.FormValue("pictureConsent") + submission.InPersonAcknowledgement = r.FormValue("inpersonAcknowledgement") + submission.AgreeToConduct = r.FormValue("agreeToConduct") + submission.InfoShareAuthorization = r.FormValue("infoShareAuthorization") + submission.AgreeToMLHEmails = r.FormValue("agreeToMLHEmails") + + resumeFile, _, err := r.FormFile("resume[]") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "invalid resume file")) + return + } + + defer resumeFile.Close() + + resumeFileBuffer := bytes.NewBuffer(nil) + + if _, err := io.Copy(resumeFileBuffer, resumeFile); err != nil { + return + } + + validate := validator.New() + if err := validate.Struct(submission); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", err.Error())) + return + } + + eventIdStr := chi.URLParam(r, "eventId") + + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + userId := ctxutils.GetUserIdFromCtx(r.Context()) + + if userId == nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "invalid user id")) + return + } + + err = h.appService.SubmitApplication(r.Context(), submission, resumeFileBuffer.Bytes(), *userId, eventId) + + if err != nil { + if err == services.ErrApplicationDeadlinePassed { + res.SendError(w, http.StatusInternalServerError, res.NewError("submit_application_error", services.ErrApplicationDeadlinePassed.Error())) + return + } + + res.SendError(w, http.StatusInternalServerError, res.NewError("submit_application_error", "Something went wrong while submitting application")) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *ApplicationHandler) SaveApplication(w http.ResponseWriter, r *http.Request) { + var data any + + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_form_data", "Something went wrong while parsing form submission")) + return + } + + eventIdStr := chi.URLParam(r, "eventId") + + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + userId := ctxutils.GetUserIdFromCtx(r.Context()) + + if userId == nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "invalid user id")) + return + } + + err = h.appService.SaveApplication(r.Context(), data, *userId, eventId) + + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("save_application_error", "Something went wrong while saving application")) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index 6af8694a..cdf231a0 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -11,13 +11,23 @@ type Handlers struct { EventInterest *EventInterestHandler Event *EventHandler Email *EmailHandler + Application *ApplicationHandler } -func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, eventService *services.EventService, emailService *services.EmailService, cfg *config.Config, logger zerolog.Logger) *Handlers { +func NewHandlers( + authService *services.AuthService, + eventInterestService *services.EventInterestService, + eventService *services.EventService, + emailService *services.EmailService, + appService *services.ApplicationService, + cfg *config.Config, + logger zerolog.Logger, +) *Handlers { return &Handlers{ Auth: NewAuthHandler(authService, cfg, logger), EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger), Event: NewEventHandler(eventService, cfg, logger), Email: NewEmailHandler(emailService, logger), + Application: NewApplicationHandler(appService), } } diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 5629fe79..77e15386 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -25,6 +25,18 @@ type AuthConfig struct { // Feel free to add more as implementations grow } +type CloudflareConfig struct { + AccountID string `env:"ACCOUNT_ID"` + AccessKeyId string `env:"ACCESS_KEY_ID"` + AccessKeySecret string `env:"ACCESS_KEY_SECRET"` +} + +type CoreBuckets struct { + Avatars string `env:"USER_AVATARS" envDefault:"core-user-avatars-dev"` + ApplicationResumes string `env:"APPLICATION_RESUMES" envDefault:"core-application-resumes-dev"` + EventAssets string `env:"EVENT_ASSETS" envDefault:"core-event-assets-dev"` +} + type Config struct { DatabaseURL string `env:"DATABASE_URL"` RedisURL string `env:"REDIS_URL"` @@ -35,6 +47,9 @@ type Config struct { Auth AuthConfig `envPrefix:"AUTH_"` Cookie CookieConfig `envPrefix:"COOKIE_"` ClientUrl string `env:"CLIENT_URL"` + + CF CloudflareConfig `envPrefix:"CF_"` + CoreBuckets CoreBuckets `envPrefix:"CORE_BUCKETS_"` } func Load() *Config { @@ -50,7 +65,7 @@ func Load() *Config { } func loadEnv() { - files := []string{".env.local", ".env.development", ".env"} + files := []string{".env.local", ".env.dev", ".env"} for _, f := range files { if _, err := os.Stat(f); err == nil { log.Info().Str("file", f).Msg("Loading environment file.") diff --git a/apps/api/internal/db/migrations/20250621222955_create_applications.sql b/apps/api/internal/db/migrations/20250621222955_create_applications.sql index 0361b3ca..ef55c0bc 100644 --- a/apps/api/internal/db/migrations/20250621222955_create_applications.sql +++ b/apps/api/internal/db/migrations/20250621222955_create_applications.sql @@ -6,6 +6,7 @@ CREATE TYPE application_status AS ENUM ('started', 'submitted', 'under_review', CREATE TABLE applications ( user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, event_id UUID REFERENCES events(id) ON DELETE CASCADE, + -- event_id UUID, status application_status DEFAULT 'started', application JSONB NOT NULL DEFAULT '{}'::JSONB, resume_url TEXT, diff --git a/apps/api/internal/db/migrations/20250825013123_remove_resume_url_column.sql b/apps/api/internal/db/migrations/20250825013123_remove_resume_url_column.sql new file mode 100644 index 00000000..8465bf5b --- /dev/null +++ b/apps/api/internal/db/migrations/20250825013123_remove_resume_url_column.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE applications DROP COLUMN IF EXISTS resume_url; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE applications ADD COLUMN resume_url TEXT; +-- +goose StatementEnd diff --git a/apps/api/internal/db/migrations/20250825033231_update_application_trigger.sql b/apps/api/internal/db/migrations/20250825033231_update_application_trigger.sql new file mode 100644 index 00000000..254f6a1f --- /dev/null +++ b/apps/api/internal/db/migrations/20250825033231_update_application_trigger.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin +DROP TRIGGER IF EXISTS set_updated_at_applications ON applications; + + +CREATE OR REPLACE FUNCTION update_application_modified_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = clock_timestamp(); + NEW.saved_at = clock_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger to update application updates +CREATE TRIGGER set_updated_at_applications +BEFORE UPDATE ON applications +FOR EACH ROW +EXECUTE FUNCTION update_application_modified_column(); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS set_updated_at_applications ON applications; +DROP FUNCTION IF EXISTS update_application_modified_column; +-- +goose StatementEnd diff --git a/apps/api/internal/db/queries/applications.sql b/apps/api/internal/db/queries/applications.sql index 2e553de7..da42da78 100644 --- a/apps/api/internal/db/queries/applications.sql +++ b/apps/api/internal/db/queries/applications.sql @@ -14,8 +14,7 @@ WHERE user_id = $1 AND event_id = $2; UPDATE applications SET status = CASE WHEN @status_do_update::boolean THEN @status::application_status ELSE status END, - application = CASE WHEN @application_do_update::boolean THEN @application::JSONB ELSE application END, - resume_url = CASE WHEN @resume_url_do_update::boolean THEN @resume_url ELSE resume_url END + application = CASE WHEN @application_do_update::boolean THEN @application::JSONB ELSE application END WHERE user_id = @user_id AND event_id = @event_id; diff --git a/apps/api/internal/db/repository/application.go b/apps/api/internal/db/repository/application.go new file mode 100644 index 00000000..9a25c183 --- /dev/null +++ b/apps/api/internal/db/repository/application.go @@ -0,0 +1,112 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +var ( + ErrCreateApplication = errors.New("unable to create application") + ErrSaveApplication = errors.New("unable to save application") + ErrSubmitApplication = errors.New("unable to submit application") + ErrInvalidApplicationData = errors.New("unable to parse application data") + ErrGetApplication = errors.New("unable to get application for user") + ErrApplicationNotFound = errors.New("can not find application for user") +) + +type ApplicationRepository struct { + db *db.DB +} + +func NewApplicationRepository(db *db.DB) *ApplicationRepository { + return &ApplicationRepository{ + db: db, + } +} + +func (r *ApplicationRepository) NewTx(tx pgx.Tx) *ApplicationRepository { + txDB := &db.DB{ + Pool: r.db.Pool, + Query: sqlc.New(tx), + } + + return &ApplicationRepository{ + db: txDB, + } +} + +func (r *ApplicationRepository) CreateApplication(ctx context.Context, params sqlc.CreateApplicationParams) (*sqlc.Application, error) { + application, err := r.db.Query.CreateApplication(ctx, params) + + if err != nil { + return nil, err + } + + return &application, nil +} + +func (r *ApplicationRepository) GetApplicationByUserAndEventID(ctx context.Context, params sqlc.GetApplicationByUserAndEventIDParams) (*sqlc.Application, error) { + application, err := r.db.Query.GetApplicationByUserAndEventID(ctx, params) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrApplicationNotFound + } + + return nil, err + } + + return &application, nil +} + +func (r *ApplicationRepository) SubmitApplication(ctx context.Context, data any, userId, eventId uuid.UUID) error { + jsonBytes, err := json.Marshal(data) + + if err != nil { + return err + } + + err = r.db.Query.UpdateApplication(ctx, sqlc.UpdateApplicationParams{ + StatusDoUpdate: true, + Status: sqlc.ApplicationStatusSubmitted, + ApplicationDoUpdate: true, + Application: jsonBytes, + UserID: userId, + EventID: eventId, + }) + + if err != nil { + return err + } + + return nil +} + +func (r *ApplicationRepository) SaveApplication(ctx context.Context, data any, userId, eventId uuid.UUID) error { + jsonBytes, err := json.Marshal(data) + + if err != nil { + return err + } + + err = r.db.Query.UpdateApplication(ctx, sqlc.UpdateApplicationParams{ + StatusDoUpdate: true, + Status: sqlc.ApplicationStatusStarted, + ApplicationDoUpdate: true, + Application: jsonBytes, + UserID: userId, + EventID: eventId, + }) + + if err != nil { + return err + } + + return nil +} diff --git a/apps/api/internal/db/sqlc/applications.sql.go b/apps/api/internal/db/sqlc/applications.sql.go index f8d9a23b..43fe3cb4 100644 --- a/apps/api/internal/db/sqlc/applications.sql.go +++ b/apps/api/internal/db/sqlc/applications.sql.go @@ -17,7 +17,7 @@ INSERT INTO applications ( ) VALUES ( $1, $2 ) -RETURNING user_id, event_id, status, application, resume_url, created_at, saved_at, updated_at +RETURNING user_id, event_id, status, application, created_at, saved_at, updated_at ` type CreateApplicationParams struct { @@ -33,7 +33,6 @@ func (q *Queries) CreateApplication(ctx context.Context, arg CreateApplicationPa &i.EventID, &i.Status, &i.Application, - &i.ResumeUrl, &i.CreatedAt, &i.SavedAt, &i.UpdatedAt, @@ -57,7 +56,7 @@ func (q *Queries) DeleteApplication(ctx context.Context, arg DeleteApplicationPa } const getApplicationByUserAndEventID = `-- name: GetApplicationByUserAndEventID :one -SELECT user_id, event_id, status, application, resume_url, created_at, saved_at, updated_at FROM applications +SELECT user_id, event_id, status, application, created_at, saved_at, updated_at FROM applications WHERE user_id = $1 AND event_id = $2 ` @@ -74,7 +73,6 @@ func (q *Queries) GetApplicationByUserAndEventID(ctx context.Context, arg GetApp &i.EventID, &i.Status, &i.Application, - &i.ResumeUrl, &i.CreatedAt, &i.SavedAt, &i.UpdatedAt, @@ -86,10 +84,9 @@ const updateApplication = `-- name: UpdateApplication :exec UPDATE applications SET status = CASE WHEN $1::boolean THEN $2::application_status ELSE status END, - application = CASE WHEN $3::boolean THEN $4::JSONB ELSE application END, - resume_url = CASE WHEN $5::boolean THEN $6 ELSE resume_url END + application = CASE WHEN $3::boolean THEN $4::JSONB ELSE application END WHERE - user_id = $7 AND event_id = $8 + user_id = $5 AND event_id = $6 ` type UpdateApplicationParams struct { @@ -97,8 +94,6 @@ type UpdateApplicationParams struct { Status ApplicationStatus `json:"status"` ApplicationDoUpdate bool `json:"application_do_update"` Application []byte `json:"application"` - ResumeUrlDoUpdate bool `json:"resume_url_do_update"` - ResumeUrl *string `json:"resume_url"` UserID uuid.UUID `json:"user_id"` EventID uuid.UUID `json:"event_id"` } @@ -109,8 +104,6 @@ func (q *Queries) UpdateApplication(ctx context.Context, arg UpdateApplicationPa arg.Status, arg.ApplicationDoUpdate, arg.Application, - arg.ResumeUrlDoUpdate, - arg.ResumeUrl, arg.UserID, arg.EventID, ) diff --git a/apps/api/internal/db/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index 159ac243..30f9a778 100644 --- a/apps/api/internal/db/sqlc/models.go +++ b/apps/api/internal/db/sqlc/models.go @@ -150,7 +150,6 @@ type Application struct { EventID uuid.UUID `json:"event_id"` Status NullApplicationStatus `json:"status"` Application []byte `json:"application"` - ResumeUrl *string `json:"resume_url"` CreatedAt time.Time `json:"created_at"` SavedAt time.Time `json:"saved_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/api/internal/ptr/uuid.go b/apps/api/internal/ptr/uuid.go new file mode 100644 index 00000000..e82fa7a2 --- /dev/null +++ b/apps/api/internal/ptr/uuid.go @@ -0,0 +1,8 @@ +package ptr + +import "github.com/google/uuid" + +// Takes a UUID and returns a pointer to that UUID +func UUIDToPtr(id uuid.UUID) *uuid.UUID { + return &id +} diff --git a/apps/api/internal/services/application.go b/apps/api/internal/services/application.go new file mode 100644 index 00000000..dd7fbec7 --- /dev/null +++ b/apps/api/internal/services/application.go @@ -0,0 +1,162 @@ +package services + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" + "github.com/swamphacks/core/apps/api/internal/ptr" + "github.com/swamphacks/core/apps/api/internal/storage" +) + +// TODO: figure out a way to create the submission fields dynamically using the json form files with proper validation. +// these fields are only applicable to swamphacks xi, not other events +type ApplicationSubmissionFields struct { + FirstName string `json:"firstName" validate:"required,max=50"` + LastName string `json:"lastName" validate:"required,max=50"` + Age int `json:"age" validate:"required,min=0,max=99"` + Phone string `json:"phone" validate:"required,len=10"` + PreferredEmail string `json:"preferredEmail" validate:"required,email"` + UniversityEmail string `json:"universityEmail" validate:"required,email"` + Linkedin string `json:"linkedin" validate:"required,url"` + Github string `json:"github" validate:"required,url"` + AgeCertification bool `json:"ageCertification" validate:"required,boolean"` + School string `json:"school" validate:"required"` + Level string `json:"level" validate:"required"` + Year string `json:"year" validate:"required"` + GraduationYear string `json:"graduationYear" validate:"required"` + Majors string `json:"majors" validate:"required"` + Minors string `json:"minors"` + Experience string `json:"experience" validate:"required"` + ProjectExperience string `json:"projectExperience" validate:"required"` + ShirtSize string `json:"shirtSize" validate:"required"` + Essay1 string `json:"essay1" validate:"required"` + Essay2 string `json:"essay2" validate:"required"` + Referral string `json:"referral" validate:"required"` + PictureConsent string `json:"pictureConsent" validate:"required"` + InPersonAcknowledgement string `json:"inpersonAcknowledgement" validate:"required"` + AgreeToConduct string `json:"agreeToConduct" validate:"required"` + InfoShareAuthorization string `json:"infoShareAuthorization" validate:"required"` + AgreeToMLHEmails string `json:"agreeToMLHEmails"` +} + +var ( + ErrApplicationDeadlinePassed = errors.New("the application deadline has passed") + ErrApplicationUnavailable = errors.New("unable to access the application") +) + +type ApplicationService struct { + appRepo *repository.ApplicationRepository + eventsService *EventService + storage storage.Storage + buckets *config.CoreBuckets + txm *db.TransactionManager + logger zerolog.Logger +} + +func NewApplicationService(appRepo *repository.ApplicationRepository, eventsService *EventService, txm *db.TransactionManager, storage storage.Storage, buckets *config.CoreBuckets, logger zerolog.Logger) *ApplicationService { + return &ApplicationService{ + appRepo: appRepo, + eventsService: eventsService, + storage: storage, + buckets: buckets, + txm: txm, + logger: logger, + } +} + +func (s *ApplicationService) GetApplicationByUserAndEventID(ctx context.Context, params sqlc.GetApplicationByUserAndEventIDParams) (*sqlc.Application, error) { + application, err := s.appRepo.GetApplicationByUserAndEventID(ctx, params) + + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return nil, err + } + + return application, nil +} + +func (s *ApplicationService) CreateApplication(ctx context.Context, params sqlc.CreateApplicationParams) (*sqlc.Application, error) { + canCreateApplication, err := s.eventsService.IsApplicationsOpen(ctx, params.EventID) + + if err != nil { + return nil, err + } + + if !canCreateApplication { + return nil, nil + } + + application, err := s.appRepo.CreateApplication(ctx, params) + + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return nil, err + } + + return application, nil +} + +func (s *ApplicationService) SubmitApplication(ctx context.Context, data ApplicationSubmissionFields, resume []byte, userId uuid.UUID, eventId uuid.UUID) error { + canSubmitApplication, err := s.eventsService.IsApplicationsOpen(ctx, eventId) + + if err != nil { + return err + } + + if !canSubmitApplication { + return ErrApplicationDeadlinePassed + } + + // Submitting application is an atomic operation + err = s.txm.WithTx(ctx, func(tx pgx.Tx) error { + txAppRepo := s.appRepo.NewTx(tx) + + err := txAppRepo.SubmitApplication(ctx, data, userId, eventId) + + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + + contentType := "application/pdf" + err = s.storage.Store(ctx, s.buckets.ApplicationResumes, eventId.String()+"/"+userId.String(), resume, &contentType) + + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + + err = s.eventsService.AssignEventRole(ctx, ptr.UUIDToPtr(userId), nil, eventId, sqlc.EventRoleTypeApplicant) + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + + return nil + }) + + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + + return nil +} + +func (s *ApplicationService) SaveApplication(ctx context.Context, data any, userId, eventId uuid.UUID) error { + err := s.appRepo.SaveApplication(ctx, data, userId, eventId) + + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + + return nil +} diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index f174329d..c159cc9f 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "time" "github.com/google/uuid" "github.com/rs/zerolog" @@ -19,6 +20,8 @@ var ( ErrFailedToParseUUID = errors.New("failed to parse uuid") ErrMissingFields = errors.New("missing fields") ErrMissingPerms = errors.New("missing perms") + + ErrFailedToSubmitApplication = errors.New("failed to submit application") ) type EventService struct { @@ -178,3 +181,19 @@ func (s *EventService) AssignEventRole( return nil } + +func (s *EventService) IsApplicationsOpen(ctx context.Context, eventId uuid.UUID) (bool, error) { + event, err := s.GetEventByID(ctx, eventId) + if err != nil { + s.logger.Err(err).Msg("ApplicationOpen check error: " + err.Error()) + return false, err + } + + if !*event.IsPublished { + return false, nil + } + + now := time.Now() + open := now.After(event.ApplicationOpen) && now.Before(event.ApplicationClose) + return open, nil +} diff --git a/apps/api/internal/storage/r2.go b/apps/api/internal/storage/r2.go new file mode 100644 index 00000000..6f4fd593 --- /dev/null +++ b/apps/api/internal/storage/r2.go @@ -0,0 +1,97 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/rs/zerolog" +) + +type R2Client struct { + client *s3.Client + logger zerolog.Logger +} + +// NewR2Client initializes a new R2Client with the provided bucket name and logger. +func NewR2Client(accountId, accessKey, secretkey string, logger zerolog.Logger) (*R2Client, error) { + cfg, err := awsConfig.LoadDefaultConfig(context.TODO(), + awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretkey, "")), + awsConfig.WithRegion("auto"), + ) + + if err != nil { + logger.Err(err).Msg("Failed to load AWS configuration") + return nil, err + } + + // Config stuff + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountId)) + }) + + return &R2Client{ + client: client, + logger: logger.With().Str("component", "s3_client").Logger(), + }, nil +} + +func (c *R2Client) Store(ctx context.Context, bucketName, key string, data []byte, contentType *string) error { + r := bytes.NewReader(data) + + _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: r, + ContentType: contentType, + }) + if err != nil { + c.logger.Err(err).Msgf("Failed to upload object to S3 with key %s", key) + return err + } + + return nil +} + +func (c *R2Client) Retrieve(ctx context.Context, bucketName, key string) ([]byte, error) { + result, err := c.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + c.logger.Err(err).Msgf("Failed to retrieve object from S3 with key %s", key) + return nil, err + } + + defer result.Body.Close() + data := new(bytes.Buffer) + if _, err := data.ReadFrom(result.Body); err != nil { + c.logger.Err(err).Msgf("Failed to read object body from S3 with key %s", key) + return nil, err + } + + return data.Bytes(), nil +} + +func (c *R2Client) Delete(ctx context.Context, bucketName, key string) error { + _, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + c.logger.Err(err).Msgf("Failed to delete object from S3 with key %s", key) + return err + } + + return nil +} + +func (c *R2Client) Close() error { + // R2Client does not require explicit closure, but you can implement any cleanup logic if needed. + // This is because the underlying S3 client does not maintain persistent connections. + return nil +} diff --git a/apps/api/internal/storage/storage.go b/apps/api/internal/storage/storage.go new file mode 100644 index 00000000..a4c9cf21 --- /dev/null +++ b/apps/api/internal/storage/storage.go @@ -0,0 +1,10 @@ +package storage + +import "context" + +type Storage interface { + Store(ctx context.Context, bucketName, key string, data []byte, contentType *string) error + Retrieve(ctx context.Context, bucketName, key string) ([]byte, error) + Delete(ctx context.Context, bucketName, key string) error + Close() error +} diff --git a/apps/web/package.json b/apps/web/package.json index b0ea75a4..48f6fa89 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.120.2", "@tanstack/react-table": "^8.21.3", + "@uidotdev/usehooks": "^2.4.1", "axios": "^1.9.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -44,6 +45,7 @@ "react-aria": "^3.41.1", "react-aria-components": "^1.10.1", "react-dom": "^19.1.0", + "react-error-boundary": "^6.0.0", "react-select": "^5.10.2", "react-stately": "^3.39.0", "react-toastify": "^11.0.5", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 0699ca39..c5a9d398 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) axios: specifier: ^1.9.0 version: 1.10.0 @@ -62,6 +65,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.1.0) react-select: specifier: ^5.10.2 version: 5.10.2(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2143,6 +2149,13 @@ packages: resolution: {integrity: sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uidotdev/usehooks@2.4.1': + resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} + engines: {node: '>=16'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@vitejs/plugin-react@4.6.0': resolution: {integrity: sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3687,6 +3700,11 @@ packages: peerDependencies: react: ^19.1.0 + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6891,6 +6909,11 @@ snapshots: '@typescript-eslint/types': 8.35.1 eslint-visitor-keys: 4.2.1 + '@uidotdev/usehooks@2.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.7 @@ -8523,6 +8546,11 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-error-boundary@6.0.0(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.6 + react: 19.1.0 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/apps/web/src/components/Form/fields/ComboBoxField.tsx b/apps/web/src/components/Form/fields/ComboBoxField.tsx index 8af5f7df..208f529f 100644 --- a/apps/web/src/components/Form/fields/ComboBoxField.tsx +++ b/apps/web/src/components/Form/fields/ComboBoxField.tsx @@ -1,7 +1,7 @@ import { useFieldContext } from "@/components/Form/formContext"; import { ComboBox, type ComboBoxProps } from "@/components/ui/ComboBox"; -export default function ComboBoxField( +export default function ComboBoxField( props: ComboBoxProps, ) { const field = useFieldContext(); diff --git a/apps/web/src/components/Form/fields/SubmitButton.tsx b/apps/web/src/components/Form/fields/SubmitButton.tsx index 48382eea..09fc4f78 100644 --- a/apps/web/src/components/Form/fields/SubmitButton.tsx +++ b/apps/web/src/components/Form/fields/SubmitButton.tsx @@ -5,14 +5,16 @@ export function SubmitButton({ label = "Submit" }: { label?: string }) { const form = useFormContext(); return ( state.canSubmit} - children={(canSubmit) => ( -
- -
- )} + selector={(state) => [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => { + return ( +
+ +
+ ); + }} /> ); } diff --git a/apps/web/src/components/Form/fields/UploadField.tsx b/apps/web/src/components/Form/fields/UploadField.tsx index 49456caa..d09b7da6 100644 --- a/apps/web/src/components/Form/fields/UploadField.tsx +++ b/apps/web/src/components/Form/fields/UploadField.tsx @@ -6,10 +6,10 @@ export default function UploadField(props: FileFieldProps) { return ( field.handleChange(files.length === 0 ? undefined : files) } + {...props} /> ); } diff --git a/apps/web/src/components/ui/FileField/FileField.tsx b/apps/web/src/components/ui/FileField/FileField.tsx index 5d2db08e..50e02f09 100644 --- a/apps/web/src/components/ui/FileField/FileField.tsx +++ b/apps/web/src/components/ui/FileField/FileField.tsx @@ -20,6 +20,8 @@ import { Description, FieldError, Label } from "@/components/ui/Field"; import { FileInput } from "./FileInput"; import type { ValidationError } from "@tanstack/react-form"; +export type TFile = File | { id: string; name: string; size: number }; + export interface FileFieldProps { name: string; isRequired?: boolean | undefined; @@ -27,25 +29,28 @@ export interface FileFieldProps { multiple?: boolean | undefined; description?: string; label?: string; - onChange?: (files: File[]) => void; + onChange?: (files: TFile[]) => void; + onNewFiles?: (files: File[]) => void; maxSize?: number; errorMessage?: string; validationBehavior?: "aria" | "native"; + defaultValue?: TFile[]; } // This context allows FileField to accept arbitrary props so that the FileInput component can use without affecting // the props of the underlying input component export const CustomPropsContext = createContext<{ - onChange?: (args: any) => void; + onChange?: (args: TFile[]) => void; + onNewFiles?: (files: File[]) => void; maxSize?: number; error?: ValidationError; resetValidation: () => void; }>(undefined!); -export const FileField = (props: FileFieldProps) => { +export const FileField = ({ defaultValue, ...props }: FileFieldProps) => { return ( - +
{props.description}
@@ -62,6 +67,7 @@ export const FileFieldWrapper = ({ multiple, label, onChange, + onNewFiles, maxSize, errorMessage, validationBehavior = "aria", @@ -132,6 +138,7 @@ export const FileFieldWrapper = ({ { +export const FileInput = ({ + defaultValue = [], +}: { + defaultValue?: TFile[]; +}): JSX.Element => { const inputRef = useRef(null); - const [files, setFiles] = useState([]); + const [files, setFiles] = useState(defaultValue); const [props] = useContextProps({} as InputProps, null, InputContext); const customProps = useContext(CustomPropsContext); @@ -46,6 +50,7 @@ export const FileInput = (): JSX.Element => { const newFiles = [...prevFiles, ...event.target.files]; customProps.onChange?.(newFiles); + customProps.onNewFiles?.([...event.target.files]); return newFiles; }); } else { @@ -63,6 +68,7 @@ export const FileInput = (): JSX.Element => { setFiles([file]); customProps.onChange?.([file]); + customProps.onNewFiles?.([file]); } }, []); @@ -77,14 +83,43 @@ export const FileInput = (): JSX.Element => { }; // not sure if we need this method if we set validationBehavior to "aria" inside the FileField component, but it should work for now. - const updateFilesForInput = (newFiles: File[]) => { + const updateFilesForInput = (newFiles: TFile[]) => { if (inputRef.current) { const dataTransfer = new DataTransfer(); - newFiles.forEach((file) => dataTransfer.items.add(file)); + newFiles.forEach( + (file) => file instanceof File && dataTransfer.items.add(file), + ); inputRef.current.files = dataTransfer.files; } }; + const renderUploadFiles = () => { + return ( +
+ {files && files.length > 0 && ( +
+ {files.map((file, i) => ( +
+
+ +

{file.name}

+
+ + onRemoveFileByIndex(i)} + className="text-sm text-gray-500 hover:text-red-500 cursor-pointer" + /> +
+ ))} +
+ )} +
+ ); + }; + const allowedFileTypes = props.accept?.split(","); return ( @@ -100,7 +135,7 @@ export const FileInput = (): JSX.Element => { (file) => file.kind === "file", ) as FileDropItem[]; - let newFiles: File[]; + let newFiles: TFile[]; if (props.multiple) { const allUploadedFiles = await Promise.all( fileDropItems.map((fileDropItem) => fileDropItem.getFile()), @@ -132,6 +167,7 @@ export const FileInput = (): JSX.Element => { } newFiles = [file]; + customProps.onNewFiles?.([file]); setFiles(newFiles); } @@ -166,28 +202,7 @@ export const FileInput = (): JSX.Element => { /> -
- {files && files.length > 0 && ( -
- {files.map((file, i) => ( -
-
- -

{file.name}

-
- - onRemoveFileByIndex(i)} - className="text-sm text-gray-500 hover:text-red-500 cursor-pointer" - /> -
- ))} -
- )} -
+ {renderUploadFiles()} ); }; diff --git a/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx b/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx index 83d8317b..87d94027 100644 --- a/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx +++ b/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx @@ -3,6 +3,7 @@ import Select, { type ClearIndicatorProps, type DropdownIndicatorProps, type MultiValueRemoveProps, + type PropsValue, } from "react-select"; import TablerChevronDown from "~icons/tabler/chevron-down"; import { @@ -68,6 +69,7 @@ export interface MultiSelectProps { options: Option[]; isRequired?: boolean; onChange?: (data: Option[]) => void; + defaultValue?: PropsValue