diff --git a/apps/api/cmd/samples/main.go b/apps/api/cmd/samples/main.go index bb068bda..f917369f 100644 --- a/apps/api/cmd/samples/main.go +++ b/apps/api/cmd/samples/main.go @@ -16,7 +16,7 @@ func main() { hackathonRepo := repository.NewHackathonRepository(db) - appOpenTime := time.Date(2026, 5, 26, 19, 13, 20, 0, time.UTC) + appOpenTime := time.Date(2026, 4, 26, 19, 13, 20, 0, time.UTC) appCloseTime := time.Date(2026, 6, 26, 19, 13, 20, 0, time.UTC) earlyAppOpenTime := time.Date(2026, 4, 20, 19, 13, 20, 0, time.UTC) earlyAppCloseTime := time.Date(2026, 4, 26, 19, 13, 20, 0, time.UTC) diff --git a/apps/api/docs/openapi.json b/apps/api/docs/openapi.json index c532b40e..d19cdd12 100644 --- a/apps/api/docs/openapi.json +++ b/apps/api/docs/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ApplicationStatistics":{"additionalProperties":false,"properties":{"ageStats":{"$ref":"#/components/schemas/GetApplicationAgeSplitRow"},"genderStats":{"$ref":"#/components/schemas/GetApplicationGenderSplitRow"},"majorStats":{"items":{"$ref":"#/components/schemas/GetApplicationMajorSplitRow"},"type":["array","null"]},"raceStats":{"items":{"$ref":"#/components/schemas/GetApplicationRaceSplitRow"},"type":["array","null"]},"schoolStats":{"items":{"$ref":"#/components/schemas/GetApplicationSchoolSplitRow"},"type":["array","null"]},"statusStats":{"$ref":"#/components/schemas/GetApplicationStatusSplitRow"}},"required":["genderStats","ageStats","raceStats","majorStats","schoolStats","statusStats"],"type":"object"},"AssignRoleBatchRequest":{"additionalProperties":false,"properties":{"assignments":{"items":{"$ref":"#/components/schemas/AssignRoleRequest"},"type":["array","null"]}},"required":["assignments"],"type":"object"},"AssignRoleRequest":{"additionalProperties":false,"properties":{"email":{"type":["string","null"]},"role":{"type":"string"},"userID":{"type":["string","null"]}},"required":["email","userID","role"],"type":"object"},"AssignedApplication":{"additionalProperties":false,"properties":{"applicantId":{"type":"string"},"status":{"type":"string"}},"required":["applicantId","status"],"type":"object"},"CheckInRequest":{"additionalProperties":false,"properties":{"rfid":{"type":["string","null"]},"userID":{"type":"string"}},"required":["userID","rfid"],"type":"object"},"CreateJoinRequest":{"additionalProperties":false,"properties":{"message":{"type":["string","null"]}},"required":["message"],"type":"object"},"CreateRedeemableRequest":{"additionalProperties":false,"properties":{"amount":{"format":"int64","minimum":1,"type":"integer"},"maxUserAmount":{"format":"int64","type":"integer"},"name":{"minLength":1,"type":"string"}},"required":["name","amount","maxUserAmount"],"type":"object"},"CreateTeamRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"FormFile":{"additionalProperties":false,"properties":{"ContentType":{"type":"string"},"Filename":{"type":"string"},"IsSet":{"type":"boolean"},"Size":{"format":"int64","type":"integer"}},"required":["ContentType","IsSet","Size","Filename"],"type":"object"},"GetApplicationAgeSplitRow":{"additionalProperties":false,"properties":{"age_18":{"format":"int64","type":"integer"},"age_19":{"format":"int64","type":"integer"},"age_20":{"format":"int64","type":"integer"},"age_21":{"format":"int64","type":"integer"},"age_22":{"format":"int64","type":"integer"},"age_23_plus":{"format":"int64","type":"integer"},"underage":{"format":"int64","type":"integer"}},"required":["underage","age_18","age_19","age_20","age_21","age_22","age_23_plus"],"type":"object"},"GetApplicationGenderSplitRow":{"additionalProperties":false,"properties":{"female":{"format":"int64","type":"integer"},"male":{"format":"int64","type":"integer"},"non_binary":{"format":"int64","type":"integer"},"other":{"format":"int64","type":"integer"}},"required":["male","female","non_binary","other"],"type":"object"},"GetApplicationMajorSplitRow":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"major":{"type":"string"}},"required":["major","count"],"type":"object"},"GetApplicationRaceSplitRow":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"race_group":{"type":"string"}},"required":["race_group","count"],"type":"object"},"GetApplicationSchoolSplitRow":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"school":{"type":"string"}},"required":["school","count"],"type":"object"},"GetApplicationStatusSplitRow":{"additionalProperties":false,"properties":{"accepted":{"format":"int64","type":"integer"},"rejected":{"format":"int64","type":"integer"},"started":{"format":"int64","type":"integer"},"submitted":{"format":"int64","type":"integer"},"under_review":{"format":"int64","type":"integer"},"waitlisted":{"format":"int64","type":"integer"},"withdrawn":{"format":"int64","type":"integer"}},"required":["started","submitted","under_review","accepted","rejected","waitlisted","withdrawn"],"type":"object"},"GetAttendeesWithDiscordRow":{"additionalProperties":false,"properties":{"discord_id":{"type":"string"},"email":{"type":["string","null"]},"name":{"type":"string"},"user_id":{"type":"string"}},"required":["discord_id","user_id","name","email"],"type":"object"},"GetRedeemablesRow":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"max_user_amount":{"format":"int32","type":"integer"},"name":{"type":"string"},"total_redeemed":{},"total_stock":{"format":"int32","type":"integer"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","total_stock","max_user_amount","created_at","updated_at","total_redeemed"],"type":"object"},"Hackathon":{"additionalProperties":false,"properties":{"accept_early_applications":{"type":"boolean"},"application_close":{"format":"date-time","type":"string"},"application_open":{"format":"date-time","type":"string"},"application_review_started":{"type":"boolean"},"banner":{"type":["string","null"]},"created_at":{"format":"date-time","type":"string"},"decision_release":{"format":"date-time","type":["string","null"]},"description":{"type":["string","null"]},"early_application_close":{"format":"date-time","type":["string","null"]},"early_application_open":{"format":"date-time","type":["string","null"]},"end_time":{"format":"date-time","type":"string"},"id":{"type":"string"},"is_active":{"type":"boolean"},"location":{"type":["string","null"]},"location_url":{"type":["string","null"]},"max_attendees":{"format":"int32","type":["integer","null"]},"name":{"type":"string"},"rsvp_deadline":{"format":"date-time","type":["string","null"]},"start_time":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","description","location","location_url","max_attendees","application_open","application_close","rsvp_deadline","decision_release","start_time","end_time","is_active","created_at","updated_at","banner","application_review_started","accept_early_applications","early_application_open","early_application_close"],"type":"object"},"HackerApplication":{"additionalProperties":false,"properties":{"application":{"contentEncoding":"base64","type":"string"},"createdAt":{"format":"date-time","type":"string"},"hackathonId":{"type":"string"},"savedAt":{"format":"date-time","type":"string"},"status":{"type":"string"},"submittedAt":{"format":"date-time","type":["string","null"]},"updatedAt":{"format":"date-time","type":"string"},"userId":{"type":"string"}},"required":["userId","status","application","createdAt","savedAt","updatedAt","submittedAt","hackathonId"],"type":"object"},"ListJoinRequestsByTeamAndStatusWithUserRow":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"processed_at":{"format":"date-time","type":["string","null"]},"processed_by_user_id":{"type":"string"},"request_message":{"type":["string","null"]},"status":{"type":"string"},"team_id":{"type":"string"},"updated_at":{"format":"date-time","type":"string"},"user_email":{"type":["string","null"]},"user_id":{"type":"string"},"user_image":{"type":["string","null"]},"user_name":{"type":"string"}},"required":["id","team_id","user_id","request_message","status","processed_by_user_id","processed_at","created_at","updated_at","user_email","user_name","user_image"],"type":"object"},"MemberWithUserInfo":{"additionalProperties":false,"properties":{"email":{"type":["string","null"]},"image":{"type":["string","null"]},"joinedAt":{"format":"date-time","type":"string"},"name":{"type":"string"},"userID":{"type":"string"}},"required":["userID","email","image","name","joinedAt"],"type":"object"},"OnboardingRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"preferredEmail":{"type":"string"}},"required":["name","preferredEmail"],"type":"object"},"PublicHackathon":{"additionalProperties":false,"properties":{"acceptEarlyApplications":{"type":"boolean"},"applicationClose":{"format":"date-time","type":"string"},"applicationOpen":{"format":"date-time","type":"string"},"banner":{"type":["string","null"]},"description":{"type":["string","null"]},"earlyApplicationClose":{"format":"date-time","type":["string","null"]},"earlyApplicationOpen":{"format":"date-time","type":["string","null"]},"endTime":{"format":"date-time","type":"string"},"id":{"type":"string"},"location":{"type":["string","null"]},"locationUrl":{"type":["string","null"]},"name":{"type":"string"},"rsvpDeadline":{"format":"date-time","type":["string","null"]},"startTime":{"format":"date-time","type":"string"}},"required":["id","name","description","location","locationUrl","applicationOpen","applicationClose","acceptEarlyApplications","earlyApplicationOpen","earlyApplicationClose","rsvpDeadline","startTime","endTime","banner"],"type":"object"},"QueueConfirmationEmailRequest":{"additionalProperties":false,"properties":{"email":{"type":"string"},"firstName":{"type":"string"}},"required":["email","firstName"],"type":"object"},"QueueTextEmailRequest":{"additionalProperties":false,"properties":{"body":{"minLength":1,"type":"string"},"subject":{"minLength":1,"type":"string"},"to":{"items":{"type":"string"},"type":["array","null"]}},"required":["to","subject","body"],"type":"object"},"QueueWelcomeEmailRequest":{"additionalProperties":false,"properties":{"email":{"type":"string"},"firstName":{"type":"string"},"recipientId":{"type":"string"}},"required":["email","firstName","recipientId"],"type":"object"},"Redeemable":{"additionalProperties":false,"properties":{"amount":{"format":"int32","type":"integer"},"created_at":{"format":"date-time","type":"string"},"hackathon_id":{"type":"string"},"id":{"type":"string"},"max_user_amount":{"format":"int32","type":"integer"},"name":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","amount","max_user_amount","created_at","updated_at","hackathon_id"],"type":"object"},"ReviewRatings":{"additionalProperties":false,"properties":{"experienceRating":{"format":"int64","maxLength":5,"minLength":1,"type":"integer"},"passionRating":{"format":"int64","maxLength":5,"minLength":1,"type":"integer"}},"required":["passionRating","experienceRating"],"type":"object"},"ReviewerAssignment":{"additionalProperties":false,"properties":{"amount":{"format":"int64","type":["integer","null"]},"userID":{"type":"string"}},"required":["userID","amount"],"type":"object"},"SubmitInterestEmailRequest":{"additionalProperties":false,"properties":{"email":{"type":"string"},"source":{"type":["string","null"]}},"required":["email","source"],"type":"object"},"Team":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"hackathon_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"owner_id":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","owner_id","created_at","updated_at","hackathon_id"],"type":"object"},"TeamJoinRequest":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"processed_at":{"format":"date-time","type":["string","null"]},"processed_by_user_id":{"type":"string"},"request_message":{"type":["string","null"]},"status":{"type":"string"},"team_id":{"type":"string"},"updated_at":{"format":"date-time","type":"string"},"user_id":{"type":"string"}},"required":["id","team_id","user_id","request_message","status","processed_by_user_id","processed_at","created_at","updated_at"],"type":"object"},"TeamWithMembers":{"additionalProperties":false,"properties":{"id":{"type":"string"},"members":{"items":{"$ref":"#/components/schemas/MemberWithUserInfo"},"type":["array","null"]},"name":{"type":"string"},"ownerId":{"type":"string"}},"required":["id","ownerId","name","members"],"type":"object"},"UpdateEmailConsentRequest":{"additionalProperties":false,"properties":{"emailConsent":{"type":"boolean"}},"required":["emailConsent"],"type":"object"},"UpdateHackathonRequest":{"additionalProperties":false,"properties":{"applicationClose":{"format":"date-time","type":"string"},"applicationOpen":{"format":"date-time","type":"string"},"decisionRelease":{"format":"date-time","type":["string","null"]},"description":{"type":["string","null"]},"endTime":{"format":"date-time","type":"string"},"location":{"type":["string","null"]},"locationUrl":{"type":["string","null"]},"maxAttendees":{"format":"int32","type":["integer","null"]},"name":{"type":"string"},"rsvpDeadline":{"format":"date-time","type":["string","null"]},"startTime":{"format":"date-time","type":"string"}},"type":"object"},"UpdateRedeemableRequest":{"additionalProperties":false,"properties":{"maxUserAmount":{"format":"int64","type":"integer"},"name":{"type":"string"},"totalStock":{"format":"int64","type":"integer"}},"type":"object"},"UpdateRedemptionRequest":{"additionalProperties":false,"properties":{"newAmount":{"format":"int64","type":"integer"}},"type":"object"},"UpdateUserRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"preferredEmail":{"type":"string"}},"required":["name","preferredEmail"],"type":"object"},"User":{"additionalProperties":false,"properties":{"checked_in_at":{"format":"date-time","type":["string","null"]},"created_at":{"format":"date-time","type":"string"},"email":{"type":["string","null"]},"email_consent":{"type":"boolean"},"email_verified":{"type":"boolean"},"id":{"type":"string"},"image":{"type":["string","null"]},"name":{"type":"string"},"onboarded":{"type":"boolean"},"preferred_email":{"type":["string","null"]},"rfid":{"type":["string","null"]},"role":{"type":"string"},"role_assigned_at":{"format":"date-time","type":["string","null"]},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","email","email_verified","onboarded","image","created_at","updated_at","preferred_email","email_consent","checked_in_at","rfid","role_assigned_at","role"],"type":"object"},"UserContext":{"additionalProperties":false,"properties":{"checkedInAt":{"format":"date-time","type":["string","null"]},"email":{"examples":["user@example.com"],"type":["string","null"]},"emailConsent":{"examples":[false],"type":"boolean"},"image":{"examples":["https://cdn.example.com/avatar.png"],"type":["string","null"]},"name":{"examples":["Jane Doe"],"type":"string"},"onboarded":{"examples":[true],"type":"boolean"},"preferredEmail":{"examples":["user.alt@example.com"],"type":["string","null"]},"rfid":{"type":["string","null"]},"role":{"enum":["admin","staff","attendee","applicant","visitor"],"type":"string"},"userId":{"examples":["550e8400-e29b-41d4-a716-446655440000"],"format":"uuid","type":"string"}},"required":["userId","email","preferredEmail","name","onboarded","image","role","emailConsent","rfid","checkedInAt"],"type":"object"}}},"info":{"title":"SwampHacks API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/application":{"get":{"description":"Get the application of the current user","operationId":"get-application","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackerApplication"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Application","tags":["Application"]}},"/application/accept-acceptance":{"patch":{"description":"Accept an acceptance after being accepted. Sets event role to attendee, from applicant.","operationId":"accept-application-acceptance","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Accept Application Acceptance","tags":["Application"]}},"/application/assigned":{"get":{"description":"Returns assigned applications and their review progress for the authenticated reviewer","operationId":"get-assigned-applications","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/AssignedApplication"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Assigned Applications","tags":["Application"]}},"/application/calculate-admissions":{"post":{"description":"Queues an admission calculation task to the BAT worker","operationId":"calculate-admissions-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Admissions Calculation Request","tags":["Application"]}},"/application/join-waitlist":{"patch":{"description":"Adds a waitlist join time to application. Sets status to waitlisted","operationId":"join-waitlist","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Join Waitlist","tags":["Application"]}},"/application/release-decisions/{runId}":{"post":{"description":"Releases decisions that were calculated by the worker from a specific run id","operationId":"release-decisions","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"runId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Release Decisions","tags":["Application"]}},"/application/resume":{"get":{"description":"Returns a presigned S3 URL with GET permission for the user's specific object, which is their uploaded resume. The client can use this URL to download the object.","operationId":"get-download-resume-url","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Resume Download URL","tags":["Application"]}},"/application/review/assign":{"post":{"description":"Assigns applications to reviewers for the application review process.","operationId":"assign-application-reviewers","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ReviewerAssignment"},"type":["array","null"]}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Assign Application Reviewers","tags":["Application"]}},"/application/review/reset":{"post":{"description":"Resets all application reviews, clearing any existing reviewer assignments.","operationId":"reset-application-reviews","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Reset Application Reviews","tags":["Application"]}},"/application/review/{applicantId}":{"post":{"description":"Handles ratings submissions from staff during the application review process","operationId":"submit-application-review","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"applicantId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewRatings"}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Application Review","tags":["Application"]}},"/application/review/{applicantId}/resume":{"get":{"description":"Returns a presigned S3 URL with GET permission for a specific user's resume as an object. The client can use this URL to download the object temporarily for application review.","operationId":"get-resume","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"applicantId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Resume URL (for review process)","tags":["Application"]}},"/application/save":{"post":{"description":"Save user's progress on the application. File/Upload fields are not saved (eg. resumes).","operationId":"save-application","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{}}}},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Save Application","tags":["Application"]}},"/application/stats":{"get":{"description":"Aggregates applications by race, gender, age, majors, and schools","operationId":"get-application-statistics","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationStatistics"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Application Statistics","tags":["Application"]}},"/application/submit":{"post":{"description":"Submit the application","operationId":"submit-application","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Application","tags":["Application"]}},"/application/transition-waitlisted-applications":{"patch":{"description":"Transitions all accepted users to waitlist, and accepts 50 from the waitlist. Sets application status from accepted to rejected.","operationId":"transition-waitlist","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Transition Waitlisted Applications","tags":["Application"]}},"/application/withdraw-acceptance":{"patch":{"description":"Withdraw an acceptance after being accepted to an event. Sets application status from accepted to rejected.","operationId":"withdraw-acceptance","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Withdraw Acceptance","tags":["Application"]}},"/application/withdraw-attendance":{"patch":{"description":"Withdraw attendance after accepting to go to the hackathon. Sets application status from accepted to withdrawn. Sets event role from attendee, back to applicant.","operationId":"withdraw-attendance","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Withdraw Attendance","tags":["Application"]}},"/auth/callback":{"get":{"description":"Handles the OAuth provider callback, validates state and nonce, and sets the session cookie.","operationId":"oauth-callback","parameters":[{"description":"OAuth authorization code","explode":false,"in":"query","name":"code","required":true,"schema":{"description":"OAuth authorization code","type":"string"}},{"description":"Base64 encoded OAuth state","explode":false,"in":"query","name":"state","required":true,"schema":{"description":"Base64 encoded OAuth state","type":"string"}},{"description":"Auth nonce cookie for CSRF protection","in":"cookie","name":"sh_auth_nonce","required":true,"schema":{"description":"Auth nonce cookie for CSRF protection","type":"string"}},{"description":"Client user agent","in":"header","name":"User-Agent","schema":{"description":"Client user agent","type":"string"}}],"responses":{"204":{"description":"No Content","headers":{"Location":{"schema":{"type":"string"}},"Set-Cookie":{"schema":{"type":"string"}}}},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"501":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Implemented"}},"summary":"OAuth Callback","tags":["Auth"]}},"/auth/logout":{"post":{"description":"Logs out the authenticated user by invalidating their session","operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Logout","tags":["Auth"]}},"/email/queue-confirmation-email":{"post":{"description":"Pushes a confirmation email request to the task queue","operationId":"queue-confirmation-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueConfirmationEmailRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Queue Confirmation Email","tags":["Email"]}},"/email/queue-text-email":{"post":{"description":"Pushes a text email request to the task queue","operationId":"queue-text-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueTextEmailRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Queue Text Email","tags":["Email"]}},"/email/queue-welcome-email":{"post":{"description":"Pushes a welcome email request to the task queue","operationId":"queue-welcome-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueWelcomeEmailRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Queue Welcome Email","tags":["Email"]}},"/email/send-welcome-emails":{"post":{"description":"Send welcome emails to all attendees","operationId":"send-welcome-emails","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Send Welcome Emails","tags":["Email"]}},"/hackathon":{"get":{"description":"Returns public information of the hackathon","operationId":"get-hackathon","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicHackathon"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon","tags":["Hackathon"]},"patch":{"description":"Updates the information of the hackathon","operationId":"update-hackathon","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateHackathonRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Hackathon","tags":["Hackathon"]}},"/hackathon/attendees/count":{"get":{"description":"Returns the number of users who is attending the hackathon","operationId":"get-hackathon-attendees-count","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"format":"int64","type":"integer"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Attendees Count","tags":["Hackathon"]}},"/hackathon/attendees/discord":{"get":{"description":"Returns all users with a discord account that is also attending the hackathon","operationId":"get-hackathon-attendees-with-discord","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/GetAttendeesWithDiscordRow"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Attendees with Discord","tags":["Hackathon"]}},"/hackathon/attendees/userids":{"get":{"description":"Returns all users ids of users who are attending the hackathon","operationId":"get-hackathon-attendees-userids","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Attendees User Ids","tags":["Hackathon"]}},"/hackathon/banner":{"delete":{"description":"Deletes the banner","operationId":"delete-banner","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Delete Banner","tags":["Hackathon"]},"post":{"description":"Uploads an image to be used as the banner for the hackathon","operationId":"upload-banner","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"encoding":{"image":{"contentType":"image/png, image/jpeg, image/jpg"}},"schema":{"properties":{"image":{"contentEncoding":"binary","contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"required":["image"],"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":["string","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Upload Banner","tags":["Hackathon"]}},"/hackathon/checkin":{"post":{"description":"Staff route for checking a user to an event. The user to check in must be an attendee and have never been checked in yet.","operationId":"check-in","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckInRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Check In User","tags":["Hackathon"]}},"/hackathon/detailed":{"get":{"description":"Returns all information of the hackathon","operationId":"get-hackathon-for-staff","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hackathon"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Detailed Hackathon","tags":["Hackathon"]}},"/hackathon/interest":{"post":{"description":"Submits an email to interest/mailing list for the hackathon","operationId":"submit-interest-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitInterestEmailRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Interest Email","tags":["Hackathon"]}},"/hackathon/staff":{"get":{"description":"Returns the users who are part of the current staff of the hackathon","operationId":"get-hackathon-staff","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/User"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Staff","tags":["Hackathon"]}},"/ping":{"get":{"description":"Health Check","operationId":"ping","responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Ping","tags":["Misc"]}},"/redeemables":{"get":{"description":"Returns a list of all redeemable items","operationId":"get-redeemables","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/GetRedeemablesRow"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Redeemables","tags":["Redeemables"]},"post":{"description":"Creates a new redeemable item","operationId":"create-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRedeemableRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Redeemable"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Create Redeemable","tags":["Redeemables"]}},"/redeemables/{redeemableId}":{"delete":{"description":"Deletes a redeemable by id","operationId":"delete-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Delete Redeemable","tags":["Redeemables"]},"patch":{"description":"Update specific fields (name, stock, max per user) of a redeemable","operationId":"update-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRedeemableRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Redeemable","tags":["Redeemables"]}},"/redeemables/{redeemableId}/users/{userID}":{"patch":{"description":"Updates a redemption created by the user.","operationId":"update-redemption","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRedemptionRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Redemption","tags":["Redeemables"]},"post":{"description":"Redeems a redeemable by id. Creates a redemption record linking a specific user to a redeemable item","operationId":"redeem-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Redeem Redeemable","tags":["Redeemables"]}},"/teams":{"post":{"description":"Creates a new team and assigns the user as the owner. Returns the team.","operationId":"create-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTeamRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Create Team","tags":["Team"]}},"/teams/me":{"get":{"description":"Returns the team information and the full list of team members for the currently authenticated user","operationId":"get-my-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamWithMembers"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get My Team","tags":["Team"]}},"/teams/me/pending-joins":{"get":{"description":"Returns the current user's pending requests for teams.","operationId":"get-my-pending-join-requests","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TeamJoinRequest"},"type":["array","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User's Pending Join Requests","tags":["Team"]}},"/teams/{requestId}/accept":{"post":{"description":"Accepts a pending team join request. Only the team owner can perform this action.","operationId":"accept-team-join-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"requestId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Accept Team Join Request","tags":["Team"]}},"/teams/{requestId}/reject":{"post":{"description":"Rejects a pending team join request. Only the team owner can perform this action.","operationId":"reject-team-join-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"requestId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Reject Team Join Request","tags":["Team"]}},"/teams/{teamId}":{"get":{"description":"Returns the team information and the full list of team members by team id","operationId":"get-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamWithMembers"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Team","tags":["Team"]}},"/teams/{teamId}/join":{"post":{"description":"Requests to join a team or fails if user is already on a team.","operationId":"create-join-team-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJoinRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamJoinRequest"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Request to Join Team","tags":["Team"]}},"/teams/{teamId}/kick/{memberId}":{"post":{"description":"Kicks a member from a team. Only the team owner can perform this action.","operationId":"kick-member-from-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"memberId","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Kick Team Member","tags":["Team"]}},"/teams/{teamId}/leave":{"post":{"description":"Leaves a team if the user is on the team.","operationId":"leave-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Leave Team","tags":["Team"]}},"/teams/{teamId}/pending-joins":{"get":{"description":"Returns a team's pending join requests. This is only allowed for the team's owner.","operationId":"get-pending-join-team-requests","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ListJoinRequestsByTeamAndStatusWithUserRow"},"type":["array","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Pending Join Requests for Team","tags":["Team"]}},"/users":{"get":{"description":"Get or search for users by name or email. If no search term is provided, returns all users with pagination.","operationId":"get-users","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"explode":false,"in":"query","name":"search","schema":{"type":"string"}},{"explode":false,"in":"query","name":"limit","schema":{"default":50,"format":"int64","type":"integer"}},{"explode":false,"in":"query","name":"offset","schema":{"default":0,"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/User"},"type":["array","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Users","tags":["Users"]}},"/users/email/{email}":{"get":{"description":"Returns the user associated with the email","operationId":"get-user-by-email","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"email","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User By Email","tags":["Users"]}},"/users/me":{"get":{"description":"Returns the authenticated user's profile","operationId":"get-me","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserContext"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Me","tags":["Users"]},"patch":{"description":"Updates information of the authenticated user","operationId":"update-user","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update User","tags":["Users"]}},"/users/me/email-consent":{"patch":{"description":"Updates the user's email consent setting","operationId":"update-email-consent","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEmailConsentRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Email Consent","tags":["Users"]}},"/users/me/onboarding":{"patch":{"description":"Allows the user to submit information such as name and preferred email, and complete the onboarding process","operationId":"onboard-user","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OnboardingRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Onboard User","tags":["Users"]}},"/users/rfid/{rfid}":{"get":{"description":"Returns the user associated with the RFID","operationId":"get-user-by-rfid","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"rfid","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User By RFID","tags":["Users"]}},"/users/roles/assign":{"post":{"description":"Assigns/modify a user's role","operationId":"assign-role","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Assign Role","tags":["Users"]}},"/users/roles/batch-assign":{"post":{"description":"Batch assign/modify multiple users' roles","operationId":"batch-assign-roles","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleBatchRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Batch Assign Roles","tags":["Users"]}},"/users/roles/revoke/{userID}":{"post":{"description":"Remove a user's role","operationId":"revoke-role","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"userID","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Revoke Role","tags":["Users"]}},"/users/userid/{userID}":{"get":{"description":"Returns the user associated with the user id","operationId":"get-user-by-id","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"userID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User By Id","tags":["Users"]}}}} \ No newline at end of file +{"components":{"schemas":{"ApplicationStatistics":{"additionalProperties":false,"properties":{"ageStats":{"$ref":"#/components/schemas/GetApplicationAgeSplitRow"},"genderStats":{"$ref":"#/components/schemas/GetApplicationGenderSplitRow"},"majorStats":{"items":{"$ref":"#/components/schemas/GetApplicationMajorSplitRow"},"type":["array","null"]},"raceStats":{"items":{"$ref":"#/components/schemas/GetApplicationRaceSplitRow"},"type":["array","null"]},"schoolStats":{"items":{"$ref":"#/components/schemas/GetApplicationSchoolSplitRow"},"type":["array","null"]},"statusStats":{"$ref":"#/components/schemas/GetApplicationStatusSplitRow"}},"required":["genderStats","ageStats","raceStats","majorStats","schoolStats","statusStats"],"type":"object"},"AssignRoleBatchRequest":{"additionalProperties":false,"properties":{"assignments":{"items":{"$ref":"#/components/schemas/AssignRoleRequest"},"type":["array","null"]}},"required":["assignments"],"type":"object"},"AssignRoleRequest":{"additionalProperties":false,"properties":{"email":{"type":["string","null"]},"role":{"type":"string"},"userID":{"type":["string","null"]}},"required":["email","userID","role"],"type":"object"},"AssignedApplication":{"additionalProperties":false,"properties":{"applicantId":{"type":"string"},"status":{"type":"string"}},"required":["applicantId","status"],"type":"object"},"CheckInRequest":{"additionalProperties":false,"properties":{"rfid":{"type":["string","null"]},"userID":{"type":"string"}},"required":["userID","rfid"],"type":"object"},"CreateJoinRequest":{"additionalProperties":false,"properties":{"message":{"type":["string","null"]}},"required":["message"],"type":"object"},"CreateRedeemableRequest":{"additionalProperties":false,"properties":{"amount":{"format":"int64","minimum":1,"type":"integer"},"maxUserAmount":{"format":"int64","type":"integer"},"name":{"minLength":1,"type":"string"}},"required":["name","amount","maxUserAmount"],"type":"object"},"CreateTeamRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"CreateWorkshopInput":{"additionalProperties":false,"properties":{"description":{"type":"string"},"end_time":{"format":"date-time","type":"string"},"location":{"type":"string"},"presenter":{"type":"string"},"start_time":{"format":"date-time","type":"string"},"title":{"type":"string"}},"required":["title","description","start_time","end_time","location","presenter"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"FormFile":{"additionalProperties":false,"properties":{"ContentType":{"type":"string"},"Filename":{"type":"string"},"IsSet":{"type":"boolean"},"Size":{"format":"int64","type":"integer"}},"required":["ContentType","IsSet","Size","Filename"],"type":"object"},"GetApplicationAgeSplitRow":{"additionalProperties":false,"properties":{"age_18":{"format":"int64","type":"integer"},"age_19":{"format":"int64","type":"integer"},"age_20":{"format":"int64","type":"integer"},"age_21":{"format":"int64","type":"integer"},"age_22":{"format":"int64","type":"integer"},"age_23_plus":{"format":"int64","type":"integer"},"underage":{"format":"int64","type":"integer"}},"required":["underage","age_18","age_19","age_20","age_21","age_22","age_23_plus"],"type":"object"},"GetApplicationGenderSplitRow":{"additionalProperties":false,"properties":{"female":{"format":"int64","type":"integer"},"male":{"format":"int64","type":"integer"},"non_binary":{"format":"int64","type":"integer"},"other":{"format":"int64","type":"integer"}},"required":["male","female","non_binary","other"],"type":"object"},"GetApplicationMajorSplitRow":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"major":{"type":"string"}},"required":["major","count"],"type":"object"},"GetApplicationRaceSplitRow":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"race_group":{"type":"string"}},"required":["race_group","count"],"type":"object"},"GetApplicationSchoolSplitRow":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"school":{"type":"string"}},"required":["school","count"],"type":"object"},"GetApplicationStatusSplitRow":{"additionalProperties":false,"properties":{"accepted":{"format":"int64","type":"integer"},"rejected":{"format":"int64","type":"integer"},"started":{"format":"int64","type":"integer"},"submitted":{"format":"int64","type":"integer"},"under_review":{"format":"int64","type":"integer"},"waitlisted":{"format":"int64","type":"integer"},"withdrawn":{"format":"int64","type":"integer"}},"required":["started","submitted","under_review","accepted","rejected","waitlisted","withdrawn"],"type":"object"},"GetAttendeesWithDiscordRow":{"additionalProperties":false,"properties":{"discord_id":{"type":"string"},"email":{"type":["string","null"]},"name":{"type":"string"},"user_id":{"type":"string"}},"required":["discord_id","user_id","name","email"],"type":"object"},"GetRedeemablesRow":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"max_user_amount":{"format":"int32","type":"integer"},"name":{"type":"string"},"total_redeemed":{},"total_stock":{"format":"int32","type":"integer"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","total_stock","max_user_amount","created_at","updated_at","total_redeemed"],"type":"object"},"Hackathon":{"additionalProperties":false,"properties":{"accept_early_applications":{"type":"boolean"},"application_close":{"format":"date-time","type":"string"},"application_open":{"format":"date-time","type":"string"},"application_review_started":{"type":"boolean"},"banner":{"type":["string","null"]},"created_at":{"format":"date-time","type":"string"},"decision_release":{"format":"date-time","type":["string","null"]},"description":{"type":["string","null"]},"early_application_close":{"format":"date-time","type":["string","null"]},"early_application_open":{"format":"date-time","type":["string","null"]},"end_time":{"format":"date-time","type":"string"},"id":{"type":"string"},"is_active":{"type":"boolean"},"location":{"type":["string","null"]},"location_url":{"type":["string","null"]},"max_attendees":{"format":"int32","type":["integer","null"]},"name":{"type":"string"},"rsvp_deadline":{"format":"date-time","type":["string","null"]},"start_time":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","description","location","location_url","max_attendees","application_open","application_close","rsvp_deadline","decision_release","start_time","end_time","is_active","created_at","updated_at","banner","application_review_started","accept_early_applications","early_application_open","early_application_close"],"type":"object"},"HackerApplication":{"additionalProperties":false,"properties":{"application":{"contentEncoding":"base64","type":"string"},"createdAt":{"format":"date-time","type":"string"},"hackathonId":{"type":"string"},"savedAt":{"format":"date-time","type":"string"},"status":{"type":"string"},"submittedAt":{"format":"date-time","type":["string","null"]},"updatedAt":{"format":"date-time","type":"string"},"userId":{"type":"string"}},"required":["userId","status","application","createdAt","savedAt","updatedAt","submittedAt","hackathonId"],"type":"object"},"ListJoinRequestsByTeamAndStatusWithUserRow":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"processed_at":{"format":"date-time","type":["string","null"]},"processed_by_user_id":{"type":"string"},"request_message":{"type":["string","null"]},"status":{"type":"string"},"team_id":{"type":"string"},"updated_at":{"format":"date-time","type":"string"},"user_email":{"type":["string","null"]},"user_id":{"type":"string"},"user_image":{"type":["string","null"]},"user_name":{"type":"string"}},"required":["id","team_id","user_id","request_message","status","processed_by_user_id","processed_at","created_at","updated_at","user_email","user_name","user_image"],"type":"object"},"MemberWithUserInfo":{"additionalProperties":false,"properties":{"email":{"type":["string","null"]},"image":{"type":["string","null"]},"joinedAt":{"format":"date-time","type":"string"},"name":{"type":"string"},"userID":{"type":"string"}},"required":["userID","email","image","name","joinedAt"],"type":"object"},"OnboardingRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"preferredEmail":{"type":"string"}},"required":["name","preferredEmail"],"type":"object"},"OpenWorkshop":{"additionalProperties":false,"properties":{"attendees":{"format":"int64","type":"integer"},"description":{"type":"string"},"end_time":{"format":"date-time","type":"string"},"id":{"type":"string"},"location":{"type":"string"},"presenter":{"type":"string"},"start_time":{"format":"date-time","type":"string"},"title":{"type":"string"}},"required":["id","title","start_time","end_time","location","description","presenter","attendees"],"type":"object"},"PublicHackathon":{"additionalProperties":false,"properties":{"acceptEarlyApplications":{"type":"boolean"},"applicationClose":{"format":"date-time","type":"string"},"applicationOpen":{"format":"date-time","type":"string"},"banner":{"type":["string","null"]},"description":{"type":["string","null"]},"earlyApplicationClose":{"format":"date-time","type":["string","null"]},"earlyApplicationOpen":{"format":"date-time","type":["string","null"]},"endTime":{"format":"date-time","type":"string"},"id":{"type":"string"},"location":{"type":["string","null"]},"locationUrl":{"type":["string","null"]},"name":{"type":"string"},"rsvpDeadline":{"format":"date-time","type":["string","null"]},"startTime":{"format":"date-time","type":"string"}},"required":["id","name","description","location","locationUrl","applicationOpen","applicationClose","acceptEarlyApplications","earlyApplicationOpen","earlyApplicationClose","rsvpDeadline","startTime","endTime","banner"],"type":"object"},"QueueConfirmationEmailRequest":{"additionalProperties":false,"properties":{"email":{"type":"string"},"firstName":{"type":"string"}},"required":["email","firstName"],"type":"object"},"QueueTextEmailRequest":{"additionalProperties":false,"properties":{"body":{"minLength":1,"type":"string"},"subject":{"minLength":1,"type":"string"},"to":{"items":{"type":"string"},"type":["array","null"]}},"required":["to","subject","body"],"type":"object"},"QueueWelcomeEmailRequest":{"additionalProperties":false,"properties":{"email":{"type":"string"},"firstName":{"type":"string"},"recipientId":{"type":"string"}},"required":["email","firstName","recipientId"],"type":"object"},"Redeemable":{"additionalProperties":false,"properties":{"amount":{"format":"int32","type":"integer"},"created_at":{"format":"date-time","type":"string"},"hackathon_id":{"type":"string"},"id":{"type":"string"},"max_user_amount":{"format":"int32","type":"integer"},"name":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","amount","max_user_amount","created_at","updated_at","hackathon_id"],"type":"object"},"ReviewRatings":{"additionalProperties":false,"properties":{"experienceRating":{"format":"int64","maxLength":5,"minLength":1,"type":"integer"},"passionRating":{"format":"int64","maxLength":5,"minLength":1,"type":"integer"}},"required":["passionRating","experienceRating"],"type":"object"},"ReviewerAssignment":{"additionalProperties":false,"properties":{"amount":{"format":"int64","type":["integer","null"]},"userID":{"type":"string"}},"required":["userID","amount"],"type":"object"},"SubmissionResult":{"additionalProperties":false,"properties":{"submittedAt":{"format":"date-time","type":["string","null"]}},"required":["submittedAt"],"type":"object"},"SubmitInterestEmailRequest":{"additionalProperties":false,"properties":{"email":{"type":"string"},"source":{"type":["string","null"]}},"required":["email","source"],"type":"object"},"Team":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"hackathon_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"owner_id":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","owner_id","created_at","updated_at","hackathon_id"],"type":"object"},"TeamJoinRequest":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"processed_at":{"format":"date-time","type":["string","null"]},"processed_by_user_id":{"type":"string"},"request_message":{"type":["string","null"]},"status":{"type":"string"},"team_id":{"type":"string"},"updated_at":{"format":"date-time","type":"string"},"user_id":{"type":"string"}},"required":["id","team_id","user_id","request_message","status","processed_by_user_id","processed_at","created_at","updated_at"],"type":"object"},"TeamWithMembers":{"additionalProperties":false,"properties":{"id":{"type":"string"},"members":{"items":{"$ref":"#/components/schemas/MemberWithUserInfo"},"type":["array","null"]},"name":{"type":"string"},"ownerId":{"type":"string"}},"required":["id","ownerId","name","members"],"type":"object"},"UpdateEmailConsentRequest":{"additionalProperties":false,"properties":{"emailConsent":{"type":"boolean"}},"required":["emailConsent"],"type":"object"},"UpdateHackathonRequest":{"additionalProperties":false,"properties":{"applicationClose":{"format":"date-time","type":"string"},"applicationOpen":{"format":"date-time","type":"string"},"decisionRelease":{"format":"date-time","type":["string","null"]},"description":{"type":["string","null"]},"endTime":{"format":"date-time","type":"string"},"location":{"type":["string","null"]},"locationUrl":{"type":["string","null"]},"maxAttendees":{"format":"int32","type":["integer","null"]},"name":{"type":"string"},"rsvpDeadline":{"format":"date-time","type":["string","null"]},"startTime":{"format":"date-time","type":"string"}},"type":"object"},"UpdateRedeemableRequest":{"additionalProperties":false,"properties":{"maxUserAmount":{"format":"int64","type":"integer"},"name":{"type":"string"},"totalStock":{"format":"int64","type":"integer"}},"type":"object"},"UpdateRedemptionRequest":{"additionalProperties":false,"properties":{"newAmount":{"format":"int64","type":"integer"}},"type":"object"},"UpdateUserRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"preferredEmail":{"type":"string"}},"required":["name","preferredEmail"],"type":"object"},"UpdateWorkshopInput":{"additionalProperties":false,"properties":{"description":{"type":["string","null"]},"end_time":{"format":"date-time","type":["string","null"]},"location":{"type":["string","null"]},"presenter":{"type":["string","null"]},"start_time":{"format":"date-time","type":["string","null"]},"title":{"type":["string","null"]}},"required":["title","description","start_time","end_time","location","presenter"],"type":"object"},"User":{"additionalProperties":false,"properties":{"checked_in_at":{"format":"date-time","type":["string","null"]},"created_at":{"format":"date-time","type":"string"},"email":{"type":["string","null"]},"email_consent":{"type":"boolean"},"email_verified":{"type":"boolean"},"has_seen_new_application_status":{"type":["boolean","null"]},"id":{"type":"string"},"image":{"type":["string","null"]},"name":{"type":"string"},"onboarded":{"type":"boolean"},"preferred_email":{"type":["string","null"]},"rfid":{"type":["string","null"]},"role":{"type":"string"},"role_assigned_at":{"format":"date-time","type":["string","null"]},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","email","email_verified","onboarded","image","created_at","updated_at","preferred_email","email_consent","checked_in_at","rfid","role_assigned_at","role","has_seen_new_application_status"],"type":"object"},"UserContext":{"additionalProperties":false,"properties":{"checkedInAt":{"format":"date-time","type":["string","null"]},"email":{"examples":["user@example.com"],"type":["string","null"]},"emailConsent":{"examples":[false],"type":"boolean"},"hasSeenNewApplicationStatus":{"type":["boolean","null"]},"image":{"examples":["https://cdn.example.com/avatar.png"],"type":["string","null"]},"name":{"examples":["Jane Doe"],"type":"string"},"onboarded":{"examples":[true],"type":"boolean"},"preferredEmail":{"examples":["user.alt@example.com"],"type":["string","null"]},"rfid":{"type":["string","null"]},"role":{"enum":["admin","staff","attendee","applicant","visitor"],"type":"string"},"userId":{"examples":["550e8400-e29b-41d4-a716-446655440000"],"format":"uuid","type":"string"}},"required":["userId","email","preferredEmail","name","onboarded","image","role","emailConsent","rfid","checkedInAt","hasSeenNewApplicationStatus"],"type":"object"},"Workshop":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"description":{"type":["string","null"]},"end_time":{"format":"date-time","type":"string"},"id":{"type":"string"},"location":{"type":["string","null"]},"num_attendees":{"format":"int32","type":"integer"},"presenter":{"type":["string","null"]},"start_time":{"format":"date-time","type":"string"},"title":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","title","description","start_time","end_time","num_attendees","location","presenter","created_at","updated_at"],"type":"object"}}},"info":{"title":"SwampHacks API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/application":{"get":{"description":"Get the application of the current user","operationId":"get-application","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackerApplication"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Application","tags":["Application"]}},"/application/accept-acceptance":{"patch":{"description":"Accept an acceptance after being accepted. Sets event role to attendee, from applicant.","operationId":"accept-application-acceptance","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Accept Application Acceptance","tags":["Application"]}},"/application/assigned":{"get":{"description":"Returns assigned applications and their review progress for the authenticated reviewer","operationId":"get-assigned-applications","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/AssignedApplication"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Assigned Applications","tags":["Application"]}},"/application/calculate-admissions":{"post":{"description":"Queues an admission calculation task to the BAT worker","operationId":"calculate-admissions-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Admissions Calculation Request","tags":["Application"]}},"/application/join-waitlist":{"patch":{"description":"Adds a waitlist join time to application. Sets status to waitlisted","operationId":"join-waitlist","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Join Waitlist","tags":["Application"]}},"/application/release-decisions/{runId}":{"post":{"description":"Releases decisions that were calculated by the worker from a specific run id","operationId":"release-decisions","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"runId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Release Decisions","tags":["Application"]}},"/application/resume":{"get":{"description":"Returns a presigned S3 URL with GET permission for the user's specific object, which is their uploaded resume. The client can use this URL to download the object.","operationId":"get-download-resume-url","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Resume Download URL","tags":["Application"]}},"/application/review/assign":{"post":{"description":"Assigns applications to reviewers for the application review process.","operationId":"assign-application-reviewers","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ReviewerAssignment"},"type":["array","null"]}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Assign Application Reviewers","tags":["Application"]}},"/application/review/reset":{"post":{"description":"Resets all application reviews, clearing any existing reviewer assignments.","operationId":"reset-application-reviews","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Reset Application Reviews","tags":["Application"]}},"/application/review/{applicantId}":{"post":{"description":"Handles ratings submissions from staff during the application review process","operationId":"submit-application-review","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"applicantId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewRatings"}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Application Review","tags":["Application"]}},"/application/review/{applicantId}/resume":{"get":{"description":"Returns a presigned S3 URL with GET permission for a specific user's resume as an object. The client can use this URL to download the object temporarily for application review.","operationId":"get-resume","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"applicantId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Resume URL (for review process)","tags":["Application"]}},"/application/save":{"post":{"description":"Save user's progress on the application. File/Upload fields are not saved (eg. resumes).","operationId":"save-application","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{}}}},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Save Application","tags":["Application"]}},"/application/stats":{"get":{"description":"Aggregates applications by race, gender, age, majors, and schools","operationId":"get-application-statistics","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationStatistics"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Application Statistics","tags":["Application"]}},"/application/submit":{"post":{"description":"Submit the application","operationId":"submit-application","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Application","tags":["Application"]}},"/application/transition-waitlisted-applications":{"patch":{"description":"Transitions all accepted users to waitlist, and accepts 50 from the waitlist. Sets application status from accepted to rejected.","operationId":"transition-waitlist","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Transition Waitlisted Applications","tags":["Application"]}},"/application/withdraw-acceptance":{"patch":{"description":"Withdraw an acceptance after being accepted to an event. Sets application status from accepted to rejected.","operationId":"withdraw-acceptance","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Withdraw Acceptance","tags":["Application"]}},"/application/withdraw-attendance":{"patch":{"description":"Withdraw attendance after accepting to go to the hackathon. Sets application status from accepted to withdrawn. Sets event role from attendee, back to applicant.","operationId":"withdraw-attendance","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Withdraw Attendance","tags":["Application"]}},"/auth/callback":{"get":{"description":"Handles the OAuth provider callback, validates state and nonce, and sets the session cookie.","operationId":"oauth-callback","parameters":[{"description":"OAuth authorization code","explode":false,"in":"query","name":"code","required":true,"schema":{"description":"OAuth authorization code","type":"string"}},{"description":"Base64 encoded OAuth state","explode":false,"in":"query","name":"state","required":true,"schema":{"description":"Base64 encoded OAuth state","type":"string"}},{"description":"Auth nonce cookie for CSRF protection","in":"cookie","name":"sh_auth_nonce","required":true,"schema":{"description":"Auth nonce cookie for CSRF protection","type":"string"}},{"description":"Client user agent","in":"header","name":"User-Agent","schema":{"description":"Client user agent","type":"string"}}],"responses":{"204":{"description":"No Content","headers":{"Location":{"schema":{"type":"string"}},"Set-Cookie":{"schema":{"type":"string"}}}},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"501":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Implemented"}},"summary":"OAuth Callback","tags":["Auth"]}},"/auth/logout":{"post":{"description":"Logs out the authenticated user by invalidating their session","operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Logout","tags":["Auth"]}},"/email/queue-confirmation-email":{"post":{"description":"Pushes a confirmation email request to the task queue","operationId":"queue-confirmation-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueConfirmationEmailRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Queue Confirmation Email","tags":["Email"]}},"/email/queue-text-email":{"post":{"description":"Pushes a text email request to the task queue","operationId":"queue-text-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueTextEmailRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Queue Text Email","tags":["Email"]}},"/email/queue-welcome-email":{"post":{"description":"Pushes a welcome email request to the task queue","operationId":"queue-welcome-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueueWelcomeEmailRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Queue Welcome Email","tags":["Email"]}},"/email/send-welcome-emails":{"post":{"description":"Send welcome emails to all attendees","operationId":"send-welcome-emails","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Send Welcome Emails","tags":["Email"]}},"/hackathon":{"get":{"description":"Returns public information of the hackathon","operationId":"get-hackathon","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicHackathon"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon","tags":["Hackathon"]},"patch":{"description":"Updates the information of the hackathon","operationId":"update-hackathon","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateHackathonRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Hackathon","tags":["Hackathon"]}},"/hackathon/attendees/count":{"get":{"description":"Returns the number of users who is attending the hackathon","operationId":"get-hackathon-attendees-count","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"format":"int64","type":"integer"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Attendees Count","tags":["Hackathon"]}},"/hackathon/attendees/discord":{"get":{"description":"Returns all users with a discord account that is also attending the hackathon","operationId":"get-hackathon-attendees-with-discord","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/GetAttendeesWithDiscordRow"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Attendees with Discord","tags":["Hackathon"]}},"/hackathon/attendees/userids":{"get":{"description":"Returns all users ids of users who are attending the hackathon","operationId":"get-hackathon-attendees-userids","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Attendees User Ids","tags":["Hackathon"]}},"/hackathon/banner":{"delete":{"description":"Deletes the banner","operationId":"delete-banner","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Delete Banner","tags":["Hackathon"]},"post":{"description":"Uploads an image to be used as the banner for the hackathon","operationId":"upload-banner","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"encoding":{"image":{"contentType":"image/png, image/jpeg, image/jpg"}},"schema":{"properties":{"image":{"contentEncoding":"binary","contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"required":["image"],"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":["string","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Upload Banner","tags":["Hackathon"]}},"/hackathon/checkin":{"post":{"description":"Staff route for checking a user to an event. The user to check in must be an attendee and have never been checked in yet.","operationId":"check-in","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckInRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Check In User","tags":["Hackathon"]}},"/hackathon/detailed":{"get":{"description":"Returns all information of the hackathon","operationId":"get-hackathon-for-staff","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hackathon"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Detailed Hackathon","tags":["Hackathon"]}},"/hackathon/interest":{"post":{"description":"Submits an email to interest/mailing list for the hackathon","operationId":"submit-interest-email","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitInterestEmailRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Submit Interest Email","tags":["Hackathon"]}},"/hackathon/staff":{"get":{"description":"Returns the users who are part of the current staff of the hackathon","operationId":"get-hackathon-staff","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/User"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Hackathon Staff","tags":["Hackathon"]}},"/ping":{"get":{"description":"Health Check","operationId":"ping","responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Ping","tags":["Misc"]}},"/redeemables":{"get":{"description":"Returns a list of all redeemable items","operationId":"get-redeemables","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/GetRedeemablesRow"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Redeemables","tags":["Redeemables"]},"post":{"description":"Creates a new redeemable item","operationId":"create-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRedeemableRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Redeemable"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Create Redeemable","tags":["Redeemables"]}},"/redeemables/{redeemableId}":{"delete":{"description":"Deletes a redeemable by id","operationId":"delete-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Delete Redeemable","tags":["Redeemables"]},"patch":{"description":"Update specific fields (name, stock, max per user) of a redeemable","operationId":"update-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRedeemableRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Redeemable","tags":["Redeemables"]}},"/redeemables/{redeemableId}/users/{userID}":{"patch":{"description":"Updates a redemption created by the user.","operationId":"update-redemption","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRedemptionRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Redemption","tags":["Redeemables"]},"post":{"description":"Redeems a redeemable by id. Creates a redemption record linking a specific user to a redeemable item","operationId":"redeem-redeemable","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"redeemableId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Redeem Redeemable","tags":["Redeemables"]}},"/teams":{"post":{"description":"Creates a new team and assigns the user as the owner. Returns the team.","operationId":"create-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTeamRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Create Team","tags":["Team"]}},"/teams/me":{"get":{"description":"Returns the team information and the full list of team members for the currently authenticated user","operationId":"get-my-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamWithMembers"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get My Team","tags":["Team"]}},"/teams/me/pending-joins":{"get":{"description":"Returns the current user's pending requests for teams.","operationId":"get-my-pending-join-requests","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TeamJoinRequest"},"type":["array","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User's Pending Join Requests","tags":["Team"]}},"/teams/{requestId}/accept":{"post":{"description":"Accepts a pending team join request. Only the team owner can perform this action.","operationId":"accept-team-join-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"requestId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Accept Team Join Request","tags":["Team"]}},"/teams/{requestId}/reject":{"post":{"description":"Rejects a pending team join request. Only the team owner can perform this action.","operationId":"reject-team-join-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"requestId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Reject Team Join Request","tags":["Team"]}},"/teams/{teamId}":{"get":{"description":"Returns the team information and the full list of team members by team id","operationId":"get-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamWithMembers"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Team","tags":["Team"]}},"/teams/{teamId}/join":{"post":{"description":"Requests to join a team or fails if user is already on a team.","operationId":"create-join-team-request","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJoinRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamJoinRequest"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Request to Join Team","tags":["Team"]}},"/teams/{teamId}/kick/{memberId}":{"post":{"description":"Kicks a member from a team. Only the team owner can perform this action.","operationId":"kick-member-from-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"memberId","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Kick Team Member","tags":["Team"]}},"/teams/{teamId}/leave":{"post":{"description":"Leaves a team if the user is on the team.","operationId":"leave-team","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Leave Team","tags":["Team"]}},"/teams/{teamId}/pending-joins":{"get":{"description":"Returns a team's pending join requests. This is only allowed for the team's owner.","operationId":"get-pending-join-team-requests","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"teamId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ListJoinRequestsByTeamAndStatusWithUserRow"},"type":["array","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Pending Join Requests for Team","tags":["Team"]}},"/users":{"get":{"description":"Get or search for users by name or email. If no search term is provided, returns all users with pagination.","operationId":"get-users","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"explode":false,"in":"query","name":"search","schema":{"type":"string"}},{"explode":false,"in":"query","name":"limit","schema":{"default":50,"format":"int64","type":"integer"}},{"explode":false,"in":"query","name":"offset","schema":{"default":0,"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/User"},"type":["array","null"]}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Users","tags":["Users"]}},"/users/email/{email}":{"get":{"description":"Returns the user associated with the email","operationId":"get-user-by-email","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"email","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User By Email","tags":["Users"]}},"/users/me":{"get":{"description":"Returns the authenticated user's profile","operationId":"get-me","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserContext"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get Me","tags":["Users"]},"patch":{"description":"Updates information of the authenticated user","operationId":"update-user","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update User","tags":["Users"]}},"/users/me/acknowledge-new-application-status":{"post":{"description":"Mark that the user has seen their new application status","operationId":"update-has-seen-new-application-status","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Acknowledge New Application Status","tags":["Users"]}},"/users/me/email-consent":{"patch":{"description":"Updates the user's email consent setting","operationId":"update-email-consent","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEmailConsentRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update Email Consent","tags":["Users"]}},"/users/me/onboarding":{"patch":{"description":"Allows the user to submit information such as name and preferred email, and complete the onboarding process","operationId":"onboard-user","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OnboardingRequest"}}},"required":true},"responses":{"200":{"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Onboard User","tags":["Users"]}},"/users/rfid/{rfid}":{"get":{"description":"Returns the user associated with the RFID","operationId":"get-user-by-rfid","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"rfid","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User By RFID","tags":["Users"]}},"/users/roles/assign":{"post":{"description":"Assigns/modify a user's role","operationId":"assign-role","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Assign Role","tags":["Users"]}},"/users/roles/batch-assign":{"post":{"description":"Batch assign/modify multiple users' roles","operationId":"batch-assign-roles","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleBatchRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Batch Assign Roles","tags":["Users"]}},"/users/roles/revoke/{userID}":{"post":{"description":"Remove a user's role","operationId":"revoke-role","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"userID","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Revoke Role","tags":["Users"]}},"/users/userid/{userID}":{"get":{"description":"Returns the user associated with the user id","operationId":"get-user-by-id","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"userID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get User By Id","tags":["Users"]}},"/workshops":{"get":{"description":"Returns a list of all workshops.","operationId":"get-all-workshops","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Workshop"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get all UPCOMING workshops","tags":["Workshops"]},"post":{"description":"Creates a workshop based on the provided body.","operationId":"create-workshop","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkshopInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenWorkshop"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Create a workshop","tags":["Workshops"]}},"/workshops/delete-all":{"delete":{"description":"Deletes all the workshops","operationId":"delete-all-workshops","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Delete all workshops","tags":["Workshops"]}},"/workshops/view-all":{"get":{"description":"Returns a list of all workshops, including past workshops.","operationId":"view-all-workshops","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Workshop"},"type":["array","null"]}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"View all workshops ever made","tags":["Workshops"]}},"/workshops/{workshopId}":{"delete":{"description":"Deletes a workshop based on the provided id.","operationId":"delete-workshop","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"workshopId","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Delete a workshop","tags":["Workshops"]},"get":{"description":"Returns a workshop based on the provided id.","operationId":"get-workshop","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"workshopId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenWorkshop"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Get a workshop off of workshopID","tags":["Workshops"]},"patch":{"description":"Updates a workshop based on the provided id and body. Only the fields provided in the body will be updated.","operationId":"update-workshop","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"workshopId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkshopInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenWorkshop"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Update a workshop","tags":["Workshops"]}},"/workshops/{workshopId}/register":{"delete":{"description":"Lets a user unregister for a workshop.","operationId":"unregister-for-workshop","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"workshopId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenWorkshop"}}},"description":"OK"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Unregister for a workshop","tags":["Workshops"]},"post":{"description":"Lets a user register for a workshop.","operationId":"register-for-workshop","parameters":[{"description":"Session cookie used to authenticate the user","in":"cookie","name":"sh_session_id","required":true,"schema":{"type":"string"}},{"in":"path","name":"workshopId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenWorkshop"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Register for a workshop","tags":["Workshops"]}}}} \ No newline at end of file diff --git a/apps/api/internal/api/middleware/auth.go b/apps/api/internal/api/middleware/auth.go index 1135eab5..f43032b9 100644 --- a/apps/api/internal/api/middleware/auth.go +++ b/apps/api/internal/api/middleware/auth.go @@ -65,6 +65,8 @@ type UserContext struct { Rfid *string `json:"rfid"` CheckedInAt *time.Time `json:"checkedInAt"` + + HasSeeNewApplicationStatus *bool `json:"hasSeenNewApplicationStatus"` } type SessionContext struct { @@ -191,16 +193,17 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { // TODO: I don't think we need UserContext here, just return sqlc.User directly userContext := UserContext{ - UserID: user.UserID, - Name: user.Name, - Email: user.Email, - PreferredEmail: user.PreferredEmail, - Image: user.Image, - Onboarded: user.Onboarded, - Role: user.Role, - EmailConsent: user.EmailConsent, - Rfid: user.Rfid, - CheckedInAt: user.CheckedInAt, + UserID: user.UserID, + Name: user.Name, + Email: user.Email, + PreferredEmail: user.PreferredEmail, + Image: user.Image, + Onboarded: user.Onboarded, + Role: user.Role, + EmailConsent: user.EmailConsent, + Rfid: user.Rfid, + CheckedInAt: user.CheckedInAt, + HasSeeNewApplicationStatus: user.HasSeenNewApplicationStatus, } sessionContext := SessionContext{ diff --git a/apps/api/internal/database/migrations/20260502234849_has_seen_application_status.sql b/apps/api/internal/database/migrations/20260502234849_has_seen_application_status.sql new file mode 100644 index 00000000..ea9bb47b --- /dev/null +++ b/apps/api/internal/database/migrations/20260502234849_has_seen_application_status.sql @@ -0,0 +1,5 @@ +-- +goose Up +alter table users add has_seen_new_application_status boolean; + +-- +goose Down +alter table users drop column has_seen_new_application_status; diff --git a/apps/api/internal/database/migrations/20260605151151_add_confirmed_type_to_application_status.sql b/apps/api/internal/database/migrations/20260605151151_add_confirmed_type_to_application_status.sql new file mode 100644 index 00000000..49600cf3 --- /dev/null +++ b/apps/api/internal/database/migrations/20260605151151_add_confirmed_type_to_application_status.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TYPE application_status +ADD VALUE IF NOT EXISTS 'confirmed'; + +-- +goose Down diff --git a/apps/api/internal/database/queries/applications.sql b/apps/api/internal/database/queries/applications.sql index d2b05256..717cc9f5 100644 --- a/apps/api/internal/database/queries/applications.sql +++ b/apps/api/internal/database/queries/applications.sql @@ -29,7 +29,7 @@ SELECT user_id FROM applications WHERE status = 'submitted' AND experience_rating IS NULL AND passion_rating IS NULL -ORDER BY +ORDER BY user_id ASC; -- name: ListAdmissionCandidates :many @@ -105,4 +105,3 @@ WHERE user_id IN ( LIMIT @acceptanceCount::int ) RETURNING user_id; - diff --git a/apps/api/internal/database/queries/sessions.sql b/apps/api/internal/database/queries/sessions.sql index a0fe6132..968a731b 100644 --- a/apps/api/internal/database/queries/sessions.sql +++ b/apps/api/internal/database/queries/sessions.sql @@ -21,7 +21,10 @@ DELETE FROM sessions WHERE expires_at < NOW(); -- name: GetActiveSessionUserInfo :one -SELECT u.id AS user_id, u.name, u.email, u.preferred_email, u.onboarded, u.image, u.role, u.email_consent, u.checked_in_at, u.rfid, s.last_used_at +SELECT u.id AS user_id, u.name, u.email, u.preferred_email, + u.onboarded, u.image, u.role, u.email_consent, + u.checked_in_at, u.rfid, u.has_seen_new_application_status, + s.last_used_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = $1 diff --git a/apps/api/internal/database/queries/users.sql b/apps/api/internal/database/queries/users.sql index e1bf0a8f..d545d058 100644 --- a/apps/api/internal/database/queries/users.sql +++ b/apps/api/internal/database/queries/users.sql @@ -40,8 +40,9 @@ SET email_consent = CASE WHEN @email_consent_do_update::boolean THEN @email_consent ELSE email_consent END, checked_in_at = CASE WHEN @checked_in_at_do_update::boolean THEN @checked_in_at ELSE checked_in_at END, rfid = CASE WHEN @rfid_do_update::boolean THEN @rfid ELSE rfid END, - role = CASE WHEN @role_do_update::boolean THEN @role ELSE role END, - role_assigned_at = CASE WHEN @role_do_update::boolean THEN NOW() ELSE role_assigned_at END, + -- role = CASE WHEN @role_do_update::boolean THEN @role ELSE role END, + -- role_assigned_at = CASE WHEN @role_do_update::boolean THEN NOW() ELSE role_assigned_at END, + has_seen_new_application_status = CASE WHEN @has_seen_new_application_status_do_update::boolean THEN @has_seen_new_application_status ELSE has_seen_new_application_status END, updated_at = NOW() WHERE id = @id::uuid; @@ -82,4 +83,4 @@ WHERE id = @user_id::uuid; -- name: UpdateRFID :exec UPDATE users SET rfid = @rfid -WHERE id = @user_id::uuid; \ No newline at end of file +WHERE id = @user_id::uuid; diff --git a/apps/api/internal/database/sqlc/applications.sql.go b/apps/api/internal/database/sqlc/applications.sql.go index 795268a1..7e48b27a 100644 --- a/apps/api/internal/database/sqlc/applications.sql.go +++ b/apps/api/internal/database/sqlc/applications.sql.go @@ -195,7 +195,7 @@ SELECT user_id FROM applications WHERE status = 'submitted' AND experience_rating IS NULL AND passion_rating IS NULL -ORDER BY +ORDER BY user_id ASC ` diff --git a/apps/api/internal/database/sqlc/hackathons.sql.go b/apps/api/internal/database/sqlc/hackathons.sql.go index 3a84b299..f5183626 100644 --- a/apps/api/internal/database/sqlc/hackathons.sql.go +++ b/apps/api/internal/database/sqlc/hackathons.sql.go @@ -217,7 +217,7 @@ func (q *Queries) GetHackathon(ctx context.Context) (Hackathon, error) { } const getStaff = `-- name: GetStaff :many -SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role FROM users +SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role, has_seen_new_application_status FROM users WHERE role IN ('admin', 'staff') ` @@ -245,6 +245,7 @@ func (q *Queries) GetStaff(ctx context.Context) ([]User, error) { &i.Rfid, &i.RoleAssignedAt, &i.Role, + &i.HasSeenNewApplicationStatus, ); err != nil { return nil, err } diff --git a/apps/api/internal/database/sqlc/models.go b/apps/api/internal/database/sqlc/models.go index 58fa9d52..bd49efef 100644 --- a/apps/api/internal/database/sqlc/models.go +++ b/apps/api/internal/database/sqlc/models.go @@ -22,6 +22,7 @@ const ( ApplicationStatusRejected ApplicationStatus = "rejected" ApplicationStatusWaitlisted ApplicationStatus = "waitlisted" ApplicationStatusWithdrawn ApplicationStatus = "withdrawn" + ApplicationStatusConfirmed ApplicationStatus = "confirmed" ) func (e *ApplicationStatus) Scan(src interface{}) error { @@ -521,20 +522,21 @@ type TeamMember struct { } type User struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Email *string `json:"email"` - EmailVerified bool `json:"email_verified"` - Onboarded bool `json:"onboarded"` - Image *string `json:"image"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - PreferredEmail *string `json:"preferred_email"` - EmailConsent bool `json:"email_consent"` - CheckedInAt *time.Time `json:"checked_in_at"` - Rfid *string `json:"rfid"` - RoleAssignedAt *time.Time `json:"role_assigned_at"` - Role UserRole `json:"role"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Email *string `json:"email"` + EmailVerified bool `json:"email_verified"` + Onboarded bool `json:"onboarded"` + Image *string `json:"image"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PreferredEmail *string `json:"preferred_email"` + EmailConsent bool `json:"email_consent"` + CheckedInAt *time.Time `json:"checked_in_at"` + Rfid *string `json:"rfid"` + RoleAssignedAt *time.Time `json:"role_assigned_at"` + Role UserRole `json:"role"` + HasSeenNewApplicationStatus *bool `json:"has_seen_new_application_status"` } type UserRedemption struct { diff --git a/apps/api/internal/database/sqlc/sessions.sql.go b/apps/api/internal/database/sqlc/sessions.sql.go index ea2f8f63..54d16a91 100644 --- a/apps/api/internal/database/sqlc/sessions.sql.go +++ b/apps/api/internal/database/sqlc/sessions.sql.go @@ -57,7 +57,10 @@ func (q *Queries) DeleteExpiredSession(ctx context.Context) error { } const getActiveSessionUserInfo = `-- name: GetActiveSessionUserInfo :one -SELECT u.id AS user_id, u.name, u.email, u.preferred_email, u.onboarded, u.image, u.role, u.email_consent, u.checked_in_at, u.rfid, s.last_used_at +SELECT u.id AS user_id, u.name, u.email, u.preferred_email, + u.onboarded, u.image, u.role, u.email_consent, + u.checked_in_at, u.rfid, u.has_seen_new_application_status, + s.last_used_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = $1 @@ -66,17 +69,18 @@ LIMIT 1 ` type GetActiveSessionUserInfoRow struct { - UserID uuid.UUID `json:"user_id"` - Name string `json:"name"` - Email *string `json:"email"` - PreferredEmail *string `json:"preferred_email"` - Onboarded bool `json:"onboarded"` - Image *string `json:"image"` - Role UserRole `json:"role"` - EmailConsent bool `json:"email_consent"` - CheckedInAt *time.Time `json:"checked_in_at"` - Rfid *string `json:"rfid"` - LastUsedAt time.Time `json:"last_used_at"` + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` + Email *string `json:"email"` + PreferredEmail *string `json:"preferred_email"` + Onboarded bool `json:"onboarded"` + Image *string `json:"image"` + Role UserRole `json:"role"` + EmailConsent bool `json:"email_consent"` + CheckedInAt *time.Time `json:"checked_in_at"` + Rfid *string `json:"rfid"` + HasSeenNewApplicationStatus *bool `json:"has_seen_new_application_status"` + LastUsedAt time.Time `json:"last_used_at"` } func (q *Queries) GetActiveSessionUserInfo(ctx context.Context, id uuid.UUID) (GetActiveSessionUserInfoRow, error) { @@ -93,6 +97,7 @@ func (q *Queries) GetActiveSessionUserInfo(ctx context.Context, id uuid.UUID) (G &i.EmailConsent, &i.CheckedInAt, &i.Rfid, + &i.HasSeenNewApplicationStatus, &i.LastUsedAt, ) return i, err diff --git a/apps/api/internal/database/sqlc/users.sql.go b/apps/api/internal/database/sqlc/users.sql.go index 489ed1d1..1c2b5a6c 100644 --- a/apps/api/internal/database/sqlc/users.sql.go +++ b/apps/api/internal/database/sqlc/users.sql.go @@ -15,7 +15,7 @@ import ( const createUser = `-- name: CreateUser :one INSERT INTO users (name, email, image) VALUES ($1, $2, $3) -RETURNING id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role +RETURNING id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role, has_seen_new_application_status ` type CreateUserParams struct { @@ -42,6 +42,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Rfid, &i.RoleAssignedAt, &i.Role, + &i.HasSeenNewApplicationStatus, ) return i, err } @@ -57,7 +58,7 @@ func (q *Queries) DeleteUser(ctx context.Context, id uuid.UUID) error { } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role FROM users +SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role, has_seen_new_application_status FROM users WHERE email = $1 ` @@ -79,12 +80,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email *string) (User, erro &i.Rfid, &i.RoleAssignedAt, &i.Role, + &i.HasSeenNewApplicationStatus, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role FROM users +SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role, has_seen_new_application_status FROM users WHERE id = $1 ` @@ -106,12 +108,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { &i.Rfid, &i.RoleAssignedAt, &i.Role, + &i.HasSeenNewApplicationStatus, ) return i, err } const getUserByRFID = `-- name: GetUserByRFID :one -SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role FROM users +SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role, has_seen_new_application_status FROM users WHERE rfid = $1 ` @@ -133,6 +136,7 @@ func (q *Queries) GetUserByRFID(ctx context.Context, rfid *string) (User, error) &i.Rfid, &i.RoleAssignedAt, &i.Role, + &i.HasSeenNewApplicationStatus, ) return i, err } @@ -170,7 +174,7 @@ func (q *Queries) GetUserEmailInfoById(ctx context.Context, id uuid.UUID) (GetUs } const getUsers = `-- name: GetUsers :many -SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role +SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, preferred_email, email_consent, checked_in_at, rfid, role_assigned_at, role, has_seen_new_application_status FROM users WHERE LOWER(name) LIKE LOWER('%' || COALESCE($1, '') || '%') OR LOWER(email) LIKE LOWER('%' || COALESCE($1, '') || '%') @@ -208,6 +212,7 @@ func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, err &i.Rfid, &i.RoleAssignedAt, &i.Role, + &i.HasSeenNewApplicationStatus, ); err != nil { return nil, err } @@ -292,35 +297,36 @@ SET email_consent = CASE WHEN $13::boolean THEN $14 ELSE email_consent END, checked_in_at = CASE WHEN $15::boolean THEN $16 ELSE checked_in_at END, rfid = CASE WHEN $17::boolean THEN $18 ELSE rfid END, - role = CASE WHEN $19::boolean THEN $20 ELSE role END, - role_assigned_at = CASE WHEN $19::boolean THEN NOW() ELSE role_assigned_at END, + -- role = CASE WHEN @role_do_update::boolean THEN @role ELSE role END, + -- role_assigned_at = CASE WHEN @role_do_update::boolean THEN NOW() ELSE role_assigned_at END, + has_seen_new_application_status = CASE WHEN $19::boolean THEN $20 ELSE has_seen_new_application_status END, updated_at = NOW() WHERE id = $21::uuid ` type UpdateUserParams struct { - NameDoUpdate bool `json:"name_do_update"` - Name string `json:"name"` - EmailDoUpdate bool `json:"email_do_update"` - Email *string `json:"email"` - EmailVerifiedDoUpdate bool `json:"email_verified_do_update"` - EmailVerified bool `json:"email_verified"` - PreferredEmailDoUpdate bool `json:"preferred_email_do_update"` - PreferredEmail *string `json:"preferred_email"` - OnboardedDoUpdate bool `json:"onboarded_do_update"` - Onboarded bool `json:"onboarded"` - ImageDoUpdate bool `json:"image_do_update"` - Image *string `json:"image"` - EmailConsentDoUpdate bool `json:"email_consent_do_update"` - EmailConsent bool `json:"email_consent"` - CheckedInAtDoUpdate bool `json:"checked_in_at_do_update"` - CheckedInAt *time.Time `json:"checked_in_at"` - RfidDoUpdate bool `json:"rfid_do_update"` - Rfid *string `json:"rfid"` - RoleDoUpdate bool `json:"role_do_update"` - Role UserRole `json:"role"` - ID uuid.UUID `json:"id"` + NameDoUpdate bool `json:"name_do_update"` + Name string `json:"name"` + EmailDoUpdate bool `json:"email_do_update"` + Email *string `json:"email"` + EmailVerifiedDoUpdate bool `json:"email_verified_do_update"` + EmailVerified bool `json:"email_verified"` + PreferredEmailDoUpdate bool `json:"preferred_email_do_update"` + PreferredEmail *string `json:"preferred_email"` + OnboardedDoUpdate bool `json:"onboarded_do_update"` + Onboarded bool `json:"onboarded"` + ImageDoUpdate bool `json:"image_do_update"` + Image *string `json:"image"` + EmailConsentDoUpdate bool `json:"email_consent_do_update"` + EmailConsent bool `json:"email_consent"` + CheckedInAtDoUpdate bool `json:"checked_in_at_do_update"` + CheckedInAt *time.Time `json:"checked_in_at"` + RfidDoUpdate bool `json:"rfid_do_update"` + Rfid *string `json:"rfid"` + HasSeenNewApplicationStatusDoUpdate bool `json:"has_seen_new_application_status_do_update"` + HasSeenNewApplicationStatus *bool `json:"has_seen_new_application_status"` + ID uuid.UUID `json:"id"` } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { @@ -343,8 +349,8 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { arg.CheckedInAt, arg.RfidDoUpdate, arg.Rfid, - arg.RoleDoUpdate, - arg.Role, + arg.HasSeenNewApplicationStatusDoUpdate, + arg.HasSeenNewApplicationStatus, arg.ID, ) return err diff --git a/apps/api/internal/domains/application/http.go b/apps/api/internal/domains/application/http.go index 6f510330..767134e7 100644 --- a/apps/api/internal/domains/application/http.go +++ b/apps/api/internal/domains/application/http.go @@ -180,30 +180,30 @@ func RegisterRoutes(applicationHandler *handler, group huma.API, mw *middleware. }, applicationHandler.handleWithdrawAcceptance) huma.Register(group, huma.Operation{ - OperationID: "withdraw-attendance", + OperationID: "withdraw-application", Method: http.MethodPatch, - Summary: "Withdraw Attendance", - Description: "Withdraw attendance after accepting to go to the hackathon. Sets application status from accepted to withdrawn. Sets event role from attendee, back to applicant.", + Summary: "Withdraw Application", + Description: "Withdraw application after being accepted to the hackthon. Sets application status from accepted to withdrawn.", Tags: []string{"Application"}, Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma}, - Path: "/withdraw-attendance", + Path: "/withdraw-application", Errors: []int{http.StatusUnauthorized, http.StatusInternalServerError}, Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, DefaultStatus: http.StatusOK, - }, applicationHandler.handleWithdrawAttendance) + }, applicationHandler.handleWithdrawApplication) huma.Register(group, huma.Operation{ - OperationID: "accept-application-acceptance", + OperationID: "confirm-attendance", Method: http.MethodPatch, - Summary: "Accept Application Acceptance", - Description: "Accept an acceptance after being accepted. Sets event role to attendee, from applicant.", + Summary: "Confirm Attendance", + Description: "Confirm attendance after being accepted. Sets event role to attendee from applicant.", Tags: []string{"Application"}, Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma}, - Path: "/accept-acceptance", + Path: "/confirm-attendance", Errors: []int{http.StatusUnauthorized, http.StatusInternalServerError}, Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, DefaultStatus: http.StatusOK, - }, applicationHandler.handleAcceptApplicationAcceptance) + }, applicationHandler.handleConfirmAttendance) huma.Register(group, huma.Operation{ OperationID: "transition-waitlist", @@ -652,44 +652,52 @@ func (h *handler) handleWithdrawAcceptance(ctx context.Context, input *struct{}) return &WithdrawAcceptanceOutput{Status: http.StatusOK}, nil } -type WithdrawAttendanceOutput struct { +type WithdrawApplicationOutput struct { Status int } -func (h *handler) handleWithdrawAttendance(ctx context.Context, input *struct{}) (*WithdrawAttendanceOutput, error) { +func (h *handler) handleWithdrawApplication(ctx context.Context, input *struct{}) (*WithdrawApplicationOutput, error) { userCtx := ctxutils.GetUserFromCtx(ctx) if userCtx == nil { return nil, huma.Error400BadRequest("Failed to get current user info") } - err := h.applicationService.WithdrawAttendance(ctx, userCtx.UserID) + if userCtx.Role != sqlc.UserRoleApplicant { + return nil, huma.Error400BadRequest("Not an applicant") + } + + err := h.applicationService.WithdrawApplication(ctx, userCtx.UserID) if err != nil { return nil, huma.Error500InternalServerError("Unable to withdraw attendance") } - return &WithdrawAttendanceOutput{Status: http.StatusOK}, nil + return &WithdrawApplicationOutput{Status: http.StatusOK}, nil } -type AcceptApplicationAcceptanceOutput struct { +type ConfirmAttendanceOutput struct { Status int } -func (h *handler) handleAcceptApplicationAcceptance(ctx context.Context, input *struct{}) (*AcceptApplicationAcceptanceOutput, error) { +func (h *handler) handleConfirmAttendance(ctx context.Context, input *struct{}) (*ConfirmAttendanceOutput, error) { userCtx := ctxutils.GetUserFromCtx(ctx) if userCtx == nil { return nil, huma.Error400BadRequest("Failed to get current user info") } - err := h.applicationService.AcceptApplicationAcceptance(ctx, userCtx.UserID) + if userCtx.Role != sqlc.UserRoleApplicant { + return nil, huma.Error400BadRequest("Not an applicant") + } + + err := h.applicationService.ConfirmAttendance(ctx, userCtx.UserID) if err != nil { return nil, huma.Error500InternalServerError("Unable to withdraw attendance") } - return &AcceptApplicationAcceptanceOutput{Status: http.StatusOK}, nil + return &ConfirmAttendanceOutput{Status: http.StatusOK}, nil } type TransitionWaitlistedApplicationsOutput struct { diff --git a/apps/api/internal/domains/application/service.go b/apps/api/internal/domains/application/service.go index 1b24b147..28a11bad 100644 --- a/apps/api/internal/domains/application/service.go +++ b/apps/api/internal/domains/application/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "github.com/google/uuid" @@ -612,7 +613,7 @@ func (s *ApplicationService) WithdrawAcceptance(ctx context.Context, userID uuid return nil } -func (s *ApplicationService) WithdrawAttendance(ctx context.Context, userID uuid.UUID) error { +func (s *ApplicationService) WithdrawApplication(ctx context.Context, userID uuid.UUID) error { // Make atomic err := s.txm.WithTx(ctx, func(tx pgx.Tx) error { txAppRepo := s.applicationRepo.NewTx(tx) @@ -640,17 +641,43 @@ func (s *ApplicationService) WithdrawAttendance(ctx context.Context, userID uuid return nil } -func (s *ApplicationService) AcceptApplicationAcceptance(ctx context.Context, userID uuid.UUID) error { - // is a check for a user being accepted necessary here? or is the frontend enough +func (s *ApplicationService) ConfirmAttendance(ctx context.Context, userID uuid.UUID) error { + // Atomic + err := s.txm.WithTx(ctx, func(tx pgx.Tx) error { + txAppRepo := s.applicationRepo.NewTx(tx) + txUserRepo := s.userRepo.NewTx(tx) + + application, err := s.applicationRepo.GetApplicationByUserId(ctx, userID) + + if err != nil { + s.logger.Err(err).Msg("ConfirmAttendance fail, unable to retrieve user application") + return err + } + + if application.Status != sqlc.ApplicationStatusAccepted { + err = errors.New("User is not accepted to hack") + s.logger.Err(err).Msg(fmt.Sprintf("ConfirmAttendance fail, application is not accepted, status: %s", application.Status)) + return err + } + + if err := txAppRepo.UpdateApplication(ctx, sqlc.UpdateApplicationParams{ + UserID: userID, + StatusDoUpdate: true, + Status: sqlc.ApplicationStatusConfirmed, + }); err != nil { + return err + } + + return txUserRepo.UpdateRole(ctx, + sqlc.UpdateRoleParams{ + UserID: userID, + Role: sqlc.UserRoleAttendee, + }, + ) + }) - err := s.userRepo.UpdateRole(ctx, - sqlc.UpdateRoleParams{ - UserID: userID, - Role: sqlc.UserRoleAttendee, - }, - ) if err != nil { - s.logger.Err(err).Msg("AcceptApplicationAcceptance fail, unable to update role") + s.logger.Err(err).Msg("ConfirmAttendance fail") return err } return nil diff --git a/apps/api/internal/domains/email/campaign_http.go b/apps/api/internal/domains/email/campaign_http.go new file mode 100644 index 00000000..a5cf754b --- /dev/null +++ b/apps/api/internal/domains/email/campaign_http.go @@ -0,0 +1,313 @@ +package email + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/api/cookie" + "github.com/swamphacks/core/apps/api/internal/api/middleware" + "github.com/swamphacks/core/apps/api/internal/ctxutils" + "github.com/swamphacks/core/apps/api/internal/database/sqlc" +) + +func RegisterCampaignRoutes(emailCampaignHandler *emailCampaignHandler, group huma.API, mw *middleware.Middleware) { + huma.Register(group, huma.Operation{ + OperationID: "create-email-campaign", + Method: http.MethodPost, + Summary: "Create Email Campaign", + Description: "Creates a saved email campaign draft for a hackathon.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusCreated, + }, emailCampaignHandler.handleCreateCampaign) + + huma.Register(group, huma.Operation{ + OperationID: "list-email-campaigns", + Method: http.MethodGet, + Summary: "List Email Campaigns", + Description: "Returns all saved email campaigns for a hackathon.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleListCampaigns) + + huma.Register(group, huma.Operation{ + OperationID: "get-email-campaign", + Method: http.MethodGet, + Summary: "Get Email Campaign", + Description: "Returns one saved email campaign by id.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns/{campaignId}", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleGetCampaign) + + huma.Register(group, huma.Operation{ + OperationID: "update-email-campaign", + Method: http.MethodPatch, + Summary: "Update Email Campaign", + Description: "Updates editable fields on a draft or scheduled email campaign.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns/{campaignId}", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleUpdateCampaign) + + huma.Register(group, huma.Operation{ + OperationID: "update-email-campaign-status", + Method: http.MethodPatch, + Summary: "Update Email Campaign Status", + Description: "Updates lifecycle fields such as status, scheduled_at, sent_at, and last_error.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns/{campaignId}/status", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleUpdateCampaignStatus) +} + +type emailCampaignHandler struct { + emailCampaignService *EmailCampaignService + logger zerolog.Logger +} + +func NewCampaignHandler(emailCampaignService *EmailCampaignService, logger zerolog.Logger) *emailCampaignHandler { + return &emailCampaignHandler{ + emailCampaignService: emailCampaignService, + logger: logger.With().Str("handler", "EmailCampaignHandler").Str("domain", "email").Logger(), + } +} + +type CreateEmailCampaignRequest struct { + HackathonID string `json:"hackathonId" required:"true"` + Title string `json:"title" minLength:"1"` + Description *string `json:"description,omitempty"` + Subject string `json:"subject" minLength:"1"` + Body string `json:"body" minLength:"1"` + Format sqlc.EmailCampaignFormat `json:"format" required:"true"` + RecipientTypes []sqlc.EmailRecipientType `json:"recipientTypes" minItems:"1"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` +} + +type UpdateEmailCampaignRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Subject *string `json:"subject,omitempty"` + Body *string `json:"body,omitempty"` + Format *sqlc.EmailCampaignFormat `json:"format,omitempty"` + RecipientTypes *[]sqlc.EmailRecipientType `json:"recipientTypes,omitempty"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` +} + +type UpdateEmailCampaignStatusRequest struct { + Status sqlc.EmailCampaignStatus `json:"status" required:"true"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` + SentAt *time.Time `json:"sentAt,omitempty"` + LastError *string `json:"lastError,omitempty"` +} + +type EmailCampaignOutput struct { + Body *sqlc.EmailCampaign +} + +type ListEmailCampaignsOutput struct { + Body []sqlc.EmailCampaign +} + +func (h *emailCampaignHandler) handleCreateCampaign(ctx context.Context, input *struct { + Body CreateEmailCampaignRequest +}) (*EmailCampaignOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + campaign, err := h.emailCampaignService.CreateCampaign(ctx, sqlc.CreateEmailCampaignParams{ + HackathonID: input.Body.HackathonID, + Title: input.Body.Title, + Description: input.Body.Description, + Subject: input.Body.Subject, + Body: input.Body.Body, + Format: input.Body.Format, + RecipientTypes: input.Body.RecipientTypes, + ScheduledAt: input.Body.ScheduledAt, + CreatedByUserID: &userCtx.UserID, + UpdatedByUserID: &userCtx.UserID, + }) + if err != nil { + return nil, campaignHTTPError(err, "Failed to create email campaign") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func (h *emailCampaignHandler) handleListCampaigns(ctx context.Context, input *struct { + HackathonID string `query:"hackathonId" required:"true"` +}) (*ListEmailCampaignsOutput, error) { + campaigns, err := h.emailCampaignService.ListCampaigns(ctx, input.HackathonID) + if err != nil { + return nil, campaignHTTPError(err, "Failed to list email campaigns") + } + + return &ListEmailCampaignsOutput{Body: campaigns}, nil +} + +func (h *emailCampaignHandler) handleGetCampaign(ctx context.Context, input *struct { + CampaignID string `path:"campaignId"` + HackathonID string `query:"hackathonId" required:"true"` +}) (*EmailCampaignOutput, error) { + campaignID, err := uuid.Parse(input.CampaignID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid campaign id") + } + + campaign, err := h.emailCampaignService.GetCampaignByID(ctx, sqlc.GetEmailCampaignByIDParams{ + ID: campaignID, + HackathonID: input.HackathonID, + }) + if err != nil { + return nil, campaignHTTPError(err, "Failed to get email campaign") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func (h *emailCampaignHandler) handleUpdateCampaign(ctx context.Context, input *struct { + CampaignID string `path:"campaignId"` + HackathonID string `query:"hackathonId" required:"true"` + Body UpdateEmailCampaignRequest +}) (*EmailCampaignOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + campaignID, err := uuid.Parse(input.CampaignID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid campaign id") + } + if input.Body.Title != nil && *input.Body.Title == "" { + return nil, huma.Error400BadRequest(ErrEmailCampaignTitleRequired.Error()) + } + if input.Body.Subject != nil && *input.Body.Subject == "" { + return nil, huma.Error400BadRequest(ErrEmailCampaignSubjectRequired.Error()) + } + if input.Body.Body != nil && *input.Body.Body == "" { + return nil, huma.Error400BadRequest(ErrEmailCampaignBodyRequired.Error()) + } + if input.Body.RecipientTypes != nil && len(*input.Body.RecipientTypes) == 0 { + return nil, huma.Error400BadRequest(ErrEmailCampaignRecipientsRequired.Error()) + } + + params := sqlc.UpdateEmailCampaignParams{ + TitleDoUpdate: input.Body.Title != nil, + DescriptionDoUpdate: input.Body.Description != nil, + SubjectDoUpdate: input.Body.Subject != nil, + BodyDoUpdate: input.Body.Body != nil, + FormatDoUpdate: input.Body.Format != nil, + RecipientTypesDoUpdate: input.Body.RecipientTypes != nil, + ScheduledAtDoUpdate: input.Body.ScheduledAt != nil, + UpdatedByUserIDDoUpdate: true, + UpdatedByUserID: userCtx.UserID, + ID: campaignID, + HackathonID: input.HackathonID, + } + + if input.Body.Title != nil { + params.Title = *input.Body.Title + } + if input.Body.Description != nil { + params.Description = input.Body.Description + } + if input.Body.Subject != nil { + params.Subject = *input.Body.Subject + } + if input.Body.Body != nil { + params.Body = *input.Body.Body + } + if input.Body.Format != nil { + params.Format = *input.Body.Format + } + if input.Body.RecipientTypes != nil { + params.RecipientTypes = *input.Body.RecipientTypes + } + if input.Body.ScheduledAt != nil { + params.ScheduledAt = input.Body.ScheduledAt + } + + campaign, err := h.emailCampaignService.UpdateCampaign(ctx, params) + if err != nil { + return nil, campaignHTTPError(err, "Failed to update email campaign") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func (h *emailCampaignHandler) handleUpdateCampaignStatus(ctx context.Context, input *struct { + CampaignID string `path:"campaignId"` + HackathonID string `query:"hackathonId" required:"true"` + Body UpdateEmailCampaignStatusRequest +}) (*EmailCampaignOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + campaignID, err := uuid.Parse(input.CampaignID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid campaign id") + } + + campaign, err := h.emailCampaignService.UpdateCampaignStatus(ctx, sqlc.UpdateEmailCampaignStatusParams{ + Status: input.Body.Status, + ScheduledAtDoUpdate: input.Body.ScheduledAt != nil, + ScheduledAt: input.Body.ScheduledAt, + SentAtDoUpdate: input.Body.SentAt != nil, + SentAt: input.Body.SentAt, + LastErrorDoUpdate: input.Body.LastError != nil, + LastError: input.Body.LastError, + UpdatedByUserIDDoUpdate: true, + UpdatedByUserID: userCtx.UserID, + ID: campaignID, + HackathonID: input.HackathonID, + }) + if err != nil { + return nil, campaignHTTPError(err, "Failed to update email campaign status") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func campaignHTTPError(err error, fallback string) error { + if errors.Is(err, ErrEmailCampaignNotFound) { + return huma.Error404NotFound("Email campaign not found") + } + + if errors.Is(err, ErrEmailCampaignCannotEdit) || + errors.Is(err, ErrEmailCampaignTitleRequired) || + errors.Is(err, ErrEmailCampaignSubjectRequired) || + errors.Is(err, ErrEmailCampaignBodyRequired) || + errors.Is(err, ErrEmailCampaignRecipientsRequired) || + errors.Is(err, ErrEmailCampaignScheduledAtRequired) || + errors.Is(err, ErrEmailCampaignSentAtRequired) { + return huma.Error400BadRequest(err.Error()) + } + + return huma.Error500InternalServerError(fallback) +} diff --git a/apps/api/internal/domains/hackathon/service.go b/apps/api/internal/domains/hackathon/service.go index 10eddea1..51789858 100644 --- a/apps/api/internal/domains/hackathon/service.go +++ b/apps/api/internal/domains/hackathon/service.go @@ -154,9 +154,6 @@ func (s *HackathonService) CheckInAttendee(ctx context.Context, userID uuid.UUID return s.userRepo.UpdateUser(ctx, sqlc.UpdateUserParams{ ID: userID, - Role: sqlc.UserRoleAttendee, - RoleDoUpdate: true, - CheckedInAt: &now, CheckedInAtDoUpdate: true, diff --git a/apps/api/internal/domains/users/http.go b/apps/api/internal/domains/users/http.go index b968ccb8..d26b9384 100644 --- a/apps/api/internal/domains/users/http.go +++ b/apps/api/internal/domains/users/http.go @@ -151,6 +151,18 @@ func RegisterRoutes(userHandler *handler, group huma.API, mw *middleware.Middlew Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusInternalServerError}, Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, }, userHandler.handleRevokeEventRole) + + huma.Register(group, huma.Operation{ + OperationID: "update-has-seen-new-application-status", + Method: http.MethodPost, + Summary: "Acknowledge New Application Status", + Description: "Mark that the user has seen their new application status", + Tags: []string{"Users"}, + Path: "/me/acknowledge-new-application-status", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + }, userHandler.handleAcknowledgeNewApplicationStatus) } type handler struct { @@ -277,16 +289,22 @@ func (h *handler) handleUpdateUser(ctx context.Context, input *struct { return nil, huma.Error400BadRequest("Invalid email format") } + var preferredEmail *string + + if input.Body.PreferredEmail != "" { + preferredEmail = &input.Body.PreferredEmail + } + // TODO: Allow/add more fields here params := sqlc.UpdateUserParams{ ID: userCtx.UserID, NameDoUpdate: true, Name: input.Body.Name, PreferredEmailDoUpdate: true, - PreferredEmail: &input.Body.PreferredEmail, + PreferredEmail: preferredEmail, } - err := h.userService.UpdateUser(ctx, userCtx.UserID, params) + err := h.userService.UpdateUser(ctx, params) if err != nil { h.logger.Err(err).Msg("failed to update user") if errors.Is(err, ErrUserNotFound) { @@ -321,11 +339,12 @@ func (h *handler) handleUpdateEmailConsent(ctx context.Context, input *struct { } params := sqlc.UpdateUserParams{ + ID: userCtx.UserID, EmailConsentDoUpdate: true, EmailConsent: input.Body.EmailConsent, } - err := h.userService.UpdateUser(ctx, userCtx.UserID, params) + err := h.userService.UpdateUser(ctx, params) if err != nil { h.logger.Err(err).Msg("failed to update email consent") @@ -496,6 +515,30 @@ func (h *handler) handleRevokeEventRole(ctx context.Context, input *struct { return &RevokeEventRoleOutput{Status: http.StatusOK}, nil } +type AcknowledgeNewApplicationStatusOutput struct { + Status int +} + +func (h *handler) handleAcknowledgeNewApplicationStatus(ctx context.Context, input *struct{}) (*AcknowledgeNewApplicationStatusOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + err := h.userService.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: userCtx.UserID, + HasSeenNewApplicationStatusDoUpdate: true, + HasSeenNewApplicationStatus: new(true), + }) + + if err != nil { + return nil, huma.Error500InternalServerError("Failed to acknowledge new application status") + } + + return &AcknowledgeNewApplicationStatusOutput{Status: http.StatusOK}, nil +} + func ParseUUIDOrNil(s *string) *uuid.UUID { if s == nil || *s == "" { return nil diff --git a/apps/api/internal/domains/users/service.go b/apps/api/internal/domains/users/service.go index 792cc822..65d67311 100644 --- a/apps/api/internal/domains/users/service.go +++ b/apps/api/internal/domains/users/service.go @@ -99,9 +99,7 @@ func (s *UserService) GetUserByRFID(ctx context.Context, rfid string) (*sqlc.Use // return checkedIn, nil // } -func (s *UserService) UpdateUser(ctx context.Context, userID uuid.UUID, params sqlc.UpdateUserParams) error { - params.ID = userID - +func (s *UserService) UpdateUser(ctx context.Context, params sqlc.UpdateUserParams) error { err := s.userRepo.UpdateUser(ctx, params) if err != nil { if err == repository.ErrUserNotFound { @@ -127,7 +125,7 @@ func (s *UserService) CompleteOnboarding(ctx context.Context, userID uuid.UUID, Onboarded: true, } - return s.UpdateUser(ctx, userID, params) + return s.UpdateUser(ctx, params) } func (s *UserService) GetAllUsers(ctx context.Context, search *string, limit, offset int32) ([]sqlc.User, error) { diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 97c325fb..6aaf5a96 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,13 +1,14 @@ # ========== Base Stage =========== -FROM node:22.16.0-slim AS base +FROM node:24.16-slim AS base WORKDIR /app # Install pnpm globally RUN npm install -g pnpm -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +ENV HUSKY=0 # Install dependencies RUN pnpm install --frozen-lockfile @@ -27,7 +28,7 @@ FROM base AS build RUN pnpm run build # ========== Production Stage (With Runtime Config Injection) =========== -FROM node:22.16.0-slim AS prod +FROM node:24.16-slim AS prod WORKDIR /app # Copy built React app (dist folder) diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml new file mode 100644 index 00000000..b2871af4 --- /dev/null +++ b/apps/web/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +ignoreScripts: false +allowBuilds: + '@tailwindcss/oxide': true + esbuild: true diff --git a/apps/web/src/components/AppShell/AppShell.tsx b/apps/web/src/components/AppShell/AppShell.tsx index 1b4d0180..3dd4ecfb 100644 --- a/apps/web/src/components/AppShell/AppShell.tsx +++ b/apps/web/src/components/AppShell/AppShell.tsx @@ -16,9 +16,6 @@ import IconX from "~icons/tabler/x"; import { auth } from "@/lib/authClient"; import { Profile } from "./Profile"; import { MobileProfile } from "@/components/AppShell/MobileProfile"; -import { Link, useLocation } from "@tanstack/react-router"; -import TablerArrowRight from "~icons/tabler/arrow-right"; -import TablerArrowLeft from "~icons/tabler/arrow-left"; interface AppShellComponent extends FC { Header: FC; @@ -50,12 +47,7 @@ const AppShellBase: FC = ({ children }) => { [children], ); const { data } = auth.useUser(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - - const isAdminPortal = pathname.startsWith("/admin"); - const user = data?.user; - const role = user?.role === "user" ? "Hacker" : "Administrator"; if (data?.error || !user) { return

Something went wrong while loading user information.

; @@ -102,28 +94,7 @@ const AppShellBase: FC = ({ children }) => {
{navbar}
- {user.role === "superuser" && ( -
- {isAdminPortal ? ( - - - Go back to Portal - - ) : ( - - Go to Admin Portal - - - )} -
- )} - +
diff --git a/apps/web/src/components/AppShell/ApplicantNavbar.tsx b/apps/web/src/components/AppShell/ApplicantNavbar.tsx new file mode 100644 index 00000000..dc39b650 --- /dev/null +++ b/apps/web/src/components/AppShell/ApplicantNavbar.tsx @@ -0,0 +1,38 @@ +import { NavLink } from "@/components/AppShell/NavLink"; +import TablerInfoCircle from "~icons/tabler/info-circle"; +import TablerClipboard from "~icons/tabler/clipboard"; +import TablerAlertCircleFilled from "~icons/tabler/alert-circle-filled"; + +interface ApplicantNavbarProps { + pathname: string; + hasSeenNewApplicationStatus: boolean | null; +} + +export default function ApplicantNavbar({ + pathname, + hasSeenNewApplicationStatus, +}: ApplicantNavbarProps) { + const commonNavLinks = ( + <> + } + active={pathname.startsWith("/information")} + /> + + } + rightSection={ + hasSeenNewApplicationStatus === false && ( + + ) + } + active={pathname.startsWith("/application")} + /> + + ); + return commonNavLinks; +} diff --git a/apps/web/src/components/AppShell/AttendeeNavbar.tsx b/apps/web/src/components/AppShell/AttendeeNavbar.tsx new file mode 100644 index 00000000..1888e999 --- /dev/null +++ b/apps/web/src/components/AppShell/AttendeeNavbar.tsx @@ -0,0 +1,27 @@ +import { NavLink } from "@/components/AppShell/NavLink"; +import TablerLayoutDashboard from "~icons/tabler/layout-dashboard"; +import TablerInfoCircle from "~icons/tabler/info-circle"; + +interface AttendeeNavbarProps { + pathname: string; +} + +export default function AttendeeNavbar({ pathname }: AttendeeNavbarProps) { + const commonNavLinks = ( + <> + } + active={pathname.startsWith("/information")} + /> + } + active={pathname.startsWith("/hacker-portal")} + /> + + ); + return commonNavLinks; +} diff --git a/apps/web/src/components/AppShell/NavLink.tsx b/apps/web/src/components/AppShell/NavLink.tsx index 4d22e7cf..7aea5f8d 100644 --- a/apps/web/src/components/AppShell/NavLink.tsx +++ b/apps/web/src/components/AppShell/NavLink.tsx @@ -1,4 +1,8 @@ -import { useEffect, type PropsWithChildren, type ReactNode } from "react"; +import React, { + useEffect, + type PropsWithChildren, + type ReactNode, +} from "react"; import TablerChevronRight from "~icons/tabler/chevron-right"; import { tv } from "tailwind-variants"; import { useToggleState } from "react-stately"; @@ -19,7 +23,7 @@ const navLink = tv({ interface NavLinkProps { href?: string; - label: string; + label: string | React.ReactNode; description?: string; leftSection?: ReactNode; rightSection?: ReactNode; @@ -63,20 +67,21 @@ const NavLink = ({
{isExpandable ? ( -
- {leftSection && ( - - {leftSection} - - )} -
- {label} - {description && ( - - {description} +
+
+ {leftSection && ( + + {leftSection} )} + {label}
+ + {description && ( + + {description} + + )}
(closeNavbarOnClick ? setMobileNavOpen(false) : null)} > -
- {leftSection && ( - - {leftSection} - - )} -
- {label} - {description && ( - - {description} +
+
+ {leftSection && ( + + {leftSection} )} + {label}
+ + {description && ( + + {description} + + )}
{rightSection && ( diff --git a/apps/web/src/components/AppShell/NavSection.tsx b/apps/web/src/components/AppShell/NavSection.tsx new file mode 100644 index 00000000..da4f3589 --- /dev/null +++ b/apps/web/src/components/AppShell/NavSection.tsx @@ -0,0 +1,12 @@ +interface NavSectionProps { + name: string; +} + +export function NavSection({ name }: NavSectionProps) { + return ( +
+

{name}

+
+
+ ); +} diff --git a/apps/web/src/components/AppShell/Profile.tsx b/apps/web/src/components/AppShell/Profile.tsx index c1e226d7..adb25a10 100644 --- a/apps/web/src/components/AppShell/Profile.tsx +++ b/apps/web/src/components/AppShell/Profile.tsx @@ -3,8 +3,15 @@ import { useAppShell } from "@/components/AppShell/AppShellContext"; import { cn } from "@/utils/cn"; import { useRouter } from "@tanstack/react-router"; import { auth } from "@/lib/authClient"; +import type { UserContext } from "@/lib/auth/types"; -export function Profile({ name, role }: { name: string; role: string }) { +export function Profile({ + name, + role, +}: { + name: string; + role: UserContext["role"]; +}) { const router = useRouter(); const { setMobileNavOpen } = useAppShell(); const { data } = auth.useUser(); @@ -22,7 +29,9 @@ export function Profile({ name, role }: { name: string; role: string }) {

{name}

-

{role}

+

+ {getRoleString(role)} +

); } + +function getRoleString(role: UserContext["role"]) { + switch (role) { + case "admin": + return "Admin"; + case "staff": + return "Staff"; + case "applicant": + return "Applicant"; + case "visitor": + return "Visitor"; + default: + return "Hacker"; + } +} diff --git a/apps/web/src/components/AppShell/VisitorNavbar.tsx b/apps/web/src/components/AppShell/VisitorNavbar.tsx new file mode 100644 index 00000000..bfd1a4a9 --- /dev/null +++ b/apps/web/src/components/AppShell/VisitorNavbar.tsx @@ -0,0 +1,29 @@ +import { NavLink } from "@/components/AppShell/NavLink"; +import TablerInfoCircle from "~icons/tabler/info-circle"; +import TablerClipboard from "~icons/tabler/clipboard"; + +interface VisitorNavbarProps { + pathname: string; +} + +export default function VisitorNavbar({ pathname }: VisitorNavbarProps) { + const commonNavLinks = ( + <> + } + active={pathname.startsWith("/information")} + /> + + } + active={pathname.startsWith("/application")} + /> + + ); + + return <>{commonNavLinks}; +} diff --git a/apps/web/src/lib/auth/hooks/useUser.ts b/apps/web/src/lib/auth/hooks/useUser.ts index 17aced69..7a2f4b64 100644 --- a/apps/web/src/lib/auth/hooks/useUser.ts +++ b/apps/web/src/lib/auth/hooks/useUser.ts @@ -1,12 +1,22 @@ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { _getUser } from "../services/user"; -export const queryKey = ["auth", "me"] as const; +export const useUserQueryKey = ["auth", "me"] as const; + +export const userQueryOptions = () => + queryOptions({ + queryKey: useUserQueryKey, + queryFn: async () => await _getUser(), + refetchOnWindowFocus: false, + refetchOnMount: true, + staleTime: 1000 * 60 * 10, // 10 minutes + retry: false, + }); export function _useUser() { // eslint-disable-next-line react-hooks/rules-of-hooks return useQuery({ - queryKey, + queryKey: useUserQueryKey, queryFn: async () => await _getUser(), refetchOnWindowFocus: false, refetchOnMount: true, diff --git a/apps/web/src/lib/auth/services/user.ts b/apps/web/src/lib/auth/services/user.ts index f296c92e..eb61d373 100644 --- a/apps/web/src/lib/auth/services/user.ts +++ b/apps/web/src/lib/auth/services/user.ts @@ -24,8 +24,6 @@ export function _logout(config: AuthConfig) { } export async function _getUser(): Promise { - console.log("fetching user info..."); - try { const res = await fetch(authConfig.AUTH_ME_URL, { credentials: "include", diff --git a/apps/web/src/lib/auth/types/user.ts b/apps/web/src/lib/auth/types/user.ts index b7b2357f..ece045c4 100644 --- a/apps/web/src/lib/auth/types/user.ts +++ b/apps/web/src/lib/auth/types/user.ts @@ -7,10 +7,11 @@ export const userContextSchema = z.object({ name: z.string(), onboarded: z.boolean(), image: z.string().nullable().optional(), - role: z.enum(['admin', 'staff', 'attendee', 'applicant', 'visitor']), + role: z.enum(["admin", "staff", "attendee", "applicant", "visitor"]), emailConsent: z.boolean(), checkedInAt: z.date().nullable(), rfid: z.string().nullable(), + hasSeenNewApplicationStatus: z.boolean().nullable(), }); export type UserContext = z.infer; diff --git a/apps/web/src/lib/authClient.ts b/apps/web/src/lib/authClient.ts index 7e7c69a2..4b1e51c0 100644 --- a/apps/web/src/lib/authClient.ts +++ b/apps/web/src/lib/authClient.ts @@ -2,7 +2,7 @@ import Auth from "./auth"; import { authConfig } from "./auth/config"; import { Discord } from "./auth/providers"; import { queryClient } from "./tanstack-query-client"; -import { queryKey as useUserQueryKey } from "./auth/hooks/useUser"; +import { useUserQueryKey } from "./auth/hooks/useUser"; export const auth = Auth({ providers: [Discord], diff --git a/apps/web/src/lib/openapi/schema.d.ts b/apps/web/src/lib/openapi/schema.d.ts index 6b011e17..3327770a 100644 --- a/apps/web/src/lib/openapi/schema.d.ts +++ b/apps/web/src/lib/openapi/schema.d.ts @@ -1008,6 +1008,26 @@ export interface paths { patch: operations["update-user"]; trace?: never; }; + "/users/me/acknowledge-new-application-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Acknowledge New Application Status + * @description Mark that the user has seen their new application status + */ + post: operations["update-has-seen-new-application-status"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/me/email-consent": { parameters: { query?: never; @@ -1148,6 +1168,122 @@ export interface paths { patch?: never; trace?: never; }; + "/workshops": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all UPCOMING workshops + * @description Returns a list of all workshops. + */ + get: operations["get-all-workshops"]; + put?: never; + /** + * Create a workshop + * @description Creates a workshop based on the provided body. + */ + post: operations["create-workshop"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workshops/delete-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete all workshops + * @description Deletes all the workshops + */ + delete: operations["delete-all-workshops"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workshops/view-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * View all workshops ever made + * @description Returns a list of all workshops, including past workshops. + */ + get: operations["view-all-workshops"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workshops/{workshopId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a workshop off of workshopID + * @description Returns a workshop based on the provided id. + */ + get: operations["get-workshop"]; + put?: never; + post?: never; + /** + * Delete a workshop + * @description Deletes a workshop based on the provided id. + */ + delete: operations["delete-workshop"]; + options?: never; + head?: never; + /** + * Update a workshop + * @description Updates a workshop based on the provided id and body. Only the fields provided in the body will be updated. + */ + patch: operations["update-workshop"]; + trace?: never; + }; + "/workshops/{workshopId}/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register for a workshop + * @description Lets a user register for a workshop. + */ + post: operations["register-for-workshop"]; + /** + * Unregister for a workshop + * @description Lets a user unregister for a workshop. + */ + delete: operations["unregister-for-workshop"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1191,6 +1327,16 @@ export interface components { CreateTeamRequest: { name: string; }; + CreateWorkshopInput: { + description: string; + /** Format: date-time */ + end_time: string; + location: string; + presenter: string; + /** Format: date-time */ + start_time: string; + title: string; + }; ErrorDetail: { /** @description Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' */ location?: string; @@ -1382,6 +1528,19 @@ export interface components { name: string; preferredEmail: string; }; + OpenWorkshop: { + /** Format: int64 */ + attendees: number; + description: string; + /** Format: date-time */ + end_time: string; + id: string; + location: string; + presenter: string; + /** Format: date-time */ + start_time: string; + title: string; + }; PublicHackathon: { acceptEarlyApplications: boolean; /** Format: date-time */ @@ -1443,6 +1602,10 @@ export interface components { amount: number | null; userID: string; }; + SubmissionResult: { + /** Format: date-time */ + submittedAt: string | null; + }; SubmitInterestEmailRequest: { email: string; source: string | null; @@ -1515,6 +1678,16 @@ export interface components { name: string; preferredEmail: string; }; + UpdateWorkshopInput: { + description: string | null; + /** Format: date-time */ + end_time: string | null; + location: string | null; + presenter: string | null; + /** Format: date-time */ + start_time: string | null; + title: string | null; + }; User: { /** Format: date-time */ checked_in_at: string | null; @@ -1523,6 +1696,7 @@ export interface components { email: string | null; email_consent: boolean; email_verified: boolean; + has_seen_new_application_status: boolean | null; id: string; image: string | null; name: string; @@ -1540,6 +1714,7 @@ export interface components { checkedInAt: string | null; email: string | null; emailConsent: boolean; + hasSeenNewApplicationStatus: boolean | null; image: string | null; name: string; onboarded: boolean; @@ -1550,6 +1725,23 @@ export interface components { /** Format: uuid */ userId: string; }; + Workshop: { + /** Format: date-time */ + created_at: string; + description: string | null; + /** Format: date-time */ + end_time: string; + id: string; + location: string | null; + /** Format: int32 */ + num_attendees: number; + presenter: string | null; + /** Format: date-time */ + start_time: string; + title: string; + /** Format: date-time */ + updated_at: string; + }; }; responses: never; parameters: never; @@ -2160,7 +2352,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["SubmissionResult"]; + }; }; /** @description Unauthorized */ 401: { @@ -4394,6 +4588,54 @@ export interface operations { }; }; }; + "update-has-seen-new-application-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; "update-email-consent": { parameters: { query?: never; @@ -4855,4 +5097,567 @@ export interface operations { }; }; }; + "get-all-workshops": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workshop"][] | null; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "create-workshop": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWorkshopInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OpenWorkshop"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-all-workshops": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "view-all-workshops": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workshop"][] | null; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "get-workshop": { + parameters: { + query?: never; + header?: never; + path: { + workshopId: string; + }; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OpenWorkshop"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "delete-workshop": { + parameters: { + query?: never; + header?: never; + path: { + workshopId: string; + }; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "update-workshop": { + parameters: { + query?: never; + header?: never; + path: { + workshopId: string; + }; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWorkshopInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OpenWorkshop"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "register-for-workshop": { + parameters: { + query?: never; + header?: never; + path: { + workshopId: string; + }; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OpenWorkshop"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "unregister-for-workshop": { + parameters: { + query?: never; + header?: never; + path: { + workshopId: string; + }; + cookie: { + /** @description Session cookie used to authenticate the user */ + sh_session_id: string; + }; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OpenWorkshop"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; } diff --git a/apps/web/src/modules/Application/ApplicationForm.tsx b/apps/web/src/modules/Application/ApplicationForm.tsx index e580a66b..0578045f 100644 --- a/apps/web/src/modules/Application/ApplicationForm.tsx +++ b/apps/web/src/modules/Application/ApplicationForm.tsx @@ -4,8 +4,6 @@ import { QuestionTypes } from "@/modules/FormBuilder/types"; import { showToast } from "@/lib/toast/toast"; import TablerCircleCheck from "~icons/tabler/circle-check"; import { api } from "@/lib/ky"; -import { Spinner } from "@/components/ui/Spinner"; -import { useMyApplication } from "@/modules/Application/hooks/useMyApplication"; import { formatDistanceToNowStrict, parseISO } from "date-fns"; import Cloud from "./assets/cloud.svg?react"; @@ -17,16 +15,22 @@ import Bell from "./assets/bell.svg?react"; // TODO: dynamically fetch application json data from somewhere (backend, cdn?) instead of hardcoding it in the frontend import data from "./application.json"; -import type { Hackathon } from "@/lib/openapi/types"; +import type { Application, Hackathon } from "@/lib/openapi/types"; import { HTTPError } from "ky"; const SAVE_DELAY_MS = 3000; // delay in time before saving form progress interface ApplicationFormProps { hackathon: Hackathon; + application: Application; + applicationResponses: any; } -export function ApplicationForm({ hackathon }: ApplicationFormProps) { +export function ApplicationForm({ + hackathon, + application, + applicationResponses, +}: ApplicationFormProps) { // TODO: make the `build` api better so components that use this function doesn't have to call useMemo on it? const { Form, fieldsTypes } = useMemo(() => build(data), []); const fileFields = useRef(new Set()); @@ -38,8 +42,6 @@ export function ApplicationForm({ hackathon }: ApplicationFormProps) { const [savedText, setSavedText] = useState(""); const [submittedAt, setSubmittedAt] = useState(undefined); - const application = useMyApplication(); - // Update saved text every second. Restart interval when lastSavedAt changes. useEffect(() => { const id = setInterval(() => { @@ -55,15 +57,15 @@ export function ApplicationForm({ hackathon }: ApplicationFormProps) { // Update saved at status message, if any. useEffect(() => { - if (!application || application.isLoading) return; + if (!application) return; - if (application.data?.savedAt) { - const parsed = parseISO(application.data.savedAt); + if (application?.savedAt) { + const parsed = parseISO(application.savedAt); setLastSavedAt(parsed); } else { setLastSavedAt(undefined); } - }, [application?.data?.savedAt, application?.isLoading]); + }, [application?.savedAt]); const onSubmit = useCallback(async (data: Record) => { setIsSubmitting(true); @@ -140,20 +142,7 @@ export function ApplicationForm({ hackathon }: ApplicationFormProps) { [isSubmitted, isSubmitting], ); - if (application.isLoading) { - return ( -
- -

Loading form...

-
- ); - } - - if (!application.data) { - throw new Error("Application data is empty."); - } - - const isApplicationSubmitted = application.data.status !== "started"; + const isApplicationSubmitted = application.status !== "started"; const saveStatus = ( <> @@ -175,9 +164,7 @@ export function ApplicationForm({ hackathon }: ApplicationFormProps) {
( )} isInvalid={isInvalid} @@ -233,7 +220,7 @@ export function ApplicationForm({ hackathon }: ApplicationFormProps) { function SubmitSuccess({ submittedAt }: { submittedAt: string }) { return (
-
+

Thank you! Your application has been received.

diff --git a/apps/web/src/modules/Application/hooks/useApplicationActions.ts b/apps/web/src/modules/Application/hooks/useApplicationActions.ts new file mode 100644 index 00000000..2038a204 --- /dev/null +++ b/apps/web/src/modules/Application/hooks/useApplicationActions.ts @@ -0,0 +1,61 @@ +import { api } from "@/lib/ky"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { HTTPError } from "ky"; +import { toast } from "react-toastify"; +import { myApplicationQueryKey } from "./useMyApplication"; +import type { ErrorResponse } from "@/lib/auth/types"; + +async function confirmAttendanceFn() { + try { + await api.patch("application/confirm-attendance"); + } catch (err) { + if (err instanceof HTTPError) { + const errorBody = await err.response.json(); + toast.error(errorBody.message || "Failed to confirm attendance."); + } else { + toast.error("An error occurred while confirming attendance."); + } + + throw err; + } +} + +async function withdrawApplicationFn() { + try { + await api.patch("application/withdraw-application"); + } catch (err) { + if (err instanceof HTTPError) { + const errorBody = await err.response.json(); + toast.error(errorBody.message || "Failed to withdraw application."); + } else { + toast.error("An error occurred while withdrawing application."); + } + + throw err; + } +} + +export function useApplicationActions() { + const queryClient = useQueryClient(); + + const confirmAttendance = useMutation({ + mutationFn: confirmAttendanceFn, + onSuccess: () => { + toast.success("Attendance confirmed!"); + queryClient.invalidateQueries({ + queryKey: myApplicationQueryKey, + }); + }, + }); + + const withdrawApplication = useMutation({ + mutationFn: withdrawApplicationFn, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: myApplicationQueryKey, + }); + }, + }); + + return { confirmAttendance, withdrawApplication }; +} diff --git a/apps/web/src/modules/Application/hooks/useMyApplication.ts b/apps/web/src/modules/Application/hooks/useMyApplication.ts index b841b093..91384b5d 100644 --- a/apps/web/src/modules/Application/hooks/useMyApplication.ts +++ b/apps/web/src/modules/Application/hooks/useMyApplication.ts @@ -1,6 +1,6 @@ import { api } from "@/lib/ky"; import type { Application } from "@/lib/openapi/types"; -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; export const myApplicationQueryKey = ["my-application"]; @@ -8,10 +8,18 @@ export async function fetchMyApplication(): Promise { return await api.get(`application`).json(); } -export function useMyApplication() { +export const myApplicationQueryOptions = () => + queryOptions({ + queryKey: myApplicationQueryKey, + queryFn: fetchMyApplication, + staleTime: 1000 * 60 * 1, // 1 minutes + }); + +export function useMyApplication(enabled: boolean = true) { return useQuery({ queryKey: myApplicationQueryKey, queryFn: () => fetchMyApplication(), staleTime: Infinity, + enabled, }); } diff --git a/apps/web/src/modules/Dashboard/ApplicantAppShell.tsx b/apps/web/src/modules/Dashboard/ApplicantAppShell.tsx deleted file mode 100644 index e1ddf49b..00000000 --- a/apps/web/src/modules/Dashboard/ApplicantAppShell.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useLocation, useRouter } from "@tanstack/react-router"; -import { AppShell } from "@/components/AppShell/AppShell"; -import { NavLink } from "@/components/AppShell/NavLink"; -import TablerProgress from "~icons/tabler/progress"; -import TablerTransformPointBottomLeft from "~icons/tabler/transform-point-bottom-left"; -import { type PropsWithChildren } from "react"; -import { Logo } from "@/components/Logo"; - -interface DashboardAppShellProps { - eventId: string; - eventName?: string; -} - -export default function ApplicantAppShell({ - eventId, - children, - eventName, -}: PropsWithChildren) { - const router = useRouter(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - - const applicationStatusActive = - /^\/events\/[^/]+\/dashboard\/application-status\/?$/.test(pathname); - const teamFormationActive = /^\/events\/[^/]+\/dashboard\/my-team\/?$/.test( - pathname, - ); - - return ( - - -
- router.navigate({ to: "/portal" })} - className="py-2 cursor-pointer" - label={eventName || "Event Portal"} - /> -
-
- - - } - active={applicationStatusActive} - /> - - } - active={teamFormationActive} - /> - - - {children} -
- ); -} diff --git a/apps/web/src/modules/Hackathon/hooks/useHackathon.ts b/apps/web/src/modules/Hackathon/hooks/useHackathon.ts index 27b38051..1ec0d740 100644 --- a/apps/web/src/modules/Hackathon/hooks/useHackathon.ts +++ b/apps/web/src/modules/Hackathon/hooks/useHackathon.ts @@ -1,6 +1,6 @@ import { api } from "@/lib/ky"; import type { Hackathon } from "@/lib/openapi/types"; -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; export const hackthonQueryKey = ["hackathon"]; @@ -8,6 +8,13 @@ export async function fetchHackathon(): Promise { return await api.get(`hackathon`).json(); } +export const hackathonQueryOptions = () => + queryOptions({ + queryKey: hackthonQueryKey, + queryFn: fetchHackathon, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + export function useHackathon() { return useQuery({ queryKey: hackthonQueryKey, diff --git a/apps/web/src/modules/Onboarding/OnboardingModal.tsx b/apps/web/src/modules/Onboarding/OnboardingModal.tsx index 02c68320..c0a1ce9f 100644 --- a/apps/web/src/modules/Onboarding/OnboardingModal.tsx +++ b/apps/web/src/modules/Onboarding/OnboardingModal.tsx @@ -12,7 +12,7 @@ import { api } from "@/lib/ky"; import { showToast } from "@/lib/toast/toast"; import Cookies from "js-cookie"; import { Button } from "@/components/ui/Button"; -import { queryKey as authQueryKey } from "@/lib/auth/hooks/useUser"; +import { useUserQueryKey } from "@/lib/auth/hooks/useUser"; import { useQueryClient } from "@tanstack/react-query"; interface OnboardingModalProps { @@ -43,11 +43,11 @@ export function OnboardingModal({ try { await api.patch("users/me/onboarding", { json: { - preferred_email: value.preferredEmail, + preferredEmail: value.preferredEmail, name: value.preferredName, }, }); - await queryClient.invalidateQueries({ queryKey: authQueryKey }); + await queryClient.invalidateQueries({ queryKey: useUserQueryKey }); showToast({ title: "Profile Updated", message: "Your profile has been updated successfully.", diff --git a/apps/web/src/modules/Settings/SettingsPage.tsx b/apps/web/src/modules/Settings/SettingsPage.tsx index 7a290b52..456c3787 100644 --- a/apps/web/src/modules/Settings/SettingsPage.tsx +++ b/apps/web/src/modules/Settings/SettingsPage.tsx @@ -7,8 +7,6 @@ import { Button } from "@/components/ui/Button"; import { cn } from "@/utils/cn"; import z from "zod"; import { useFormErrors } from "@/components/Form"; -import { api } from "@/lib/ky"; -import { auth } from "@/lib/authClient"; import { showToast } from "@/lib/toast/toast"; import { useState } from "react"; import TablerPlaystationX from "~icons/tabler/playstation-x"; @@ -21,16 +19,17 @@ import TablerLogout from "~icons/tabler/logout"; import { useRouter, useCanGoBack } from "@tanstack/react-router"; import TablerHome from "~icons/tabler/home"; import { ThemeSwitch } from "@/components/ThemeProvider"; +import type { UserContext } from "@/lib/auth/types"; -export function SettingsPage({ logout }: { logout: () => void }) { +interface SettingsPageProps { + logout: () => void; + user: UserContext | null; +} + +export function SettingsPage({ logout, user }: SettingsPageProps) { const router = useRouter(); const canGoBack = useCanGoBack(); - - const { data } = auth.useUser(); - const { user } = data!; - - const { updateAccountInfo } = useSettingsActions(); - + const { updateAccountInfo, updateEmailConsent } = useSettingsActions(); const [emailConsent, setEmailConsent] = useState(user?.emailConsent); const form = useForm({ @@ -42,6 +41,10 @@ export function SettingsPage({ logout }: { logout: () => void }) { onChange: settingsFieldsSchema, }, onSubmit: async ({ value }) => { + if (value.preferredEmail === null || value.preferredEmail === undefined) { + value.preferredEmail = ""; + } + await updateAccountInfo.mutateAsync( value as z.infer, { @@ -58,9 +61,7 @@ export function SettingsPage({ logout }: { logout: () => void }) { const handleEmailConsentToggle = async (selected: boolean) => { try { setEmailConsent(selected); - await api.patch("users/me/email-consent", { - json: { email_consent: selected }, - }); + await updateEmailConsent.mutateAsync(selected); } catch { showToast({ title: "Something went wrong :(", @@ -71,6 +72,14 @@ export function SettingsPage({ logout }: { logout: () => void }) { } }; + if (!user) { + return ( +
+

Unable to load user settings...

+
+ ); + } + return (
@@ -85,7 +94,7 @@ export function SettingsPage({ logout }: { logout: () => void }) { /> ) : ( router.navigate({ to: "/portal" })} + onClick={() => router.navigate({ to: "/information" })} className="size-7 hover:cursor-pointer text-text-secondary hover:text-text-main" /> )} diff --git a/apps/web/src/modules/Settings/hooks/useSettingsActions.tsx b/apps/web/src/modules/Settings/hooks/useSettingsActions.tsx index 3668aa03..12d4a051 100644 --- a/apps/web/src/modules/Settings/hooks/useSettingsActions.tsx +++ b/apps/web/src/modules/Settings/hooks/useSettingsActions.tsx @@ -1,14 +1,15 @@ import z from "zod"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/ky"; -import { queryKey as authQueryKey } from "@/lib/auth/hooks/useUser"; +import { useUserQueryKey } from "@/lib/auth/hooks/useUser"; export const settingsFieldsSchema = z.object({ name: z.string().min(1, "Name is required"), - preferredEmail: z.preprocess( - (val: string) => (val === "" ? undefined : val), - z.email("Email address is invalid").optional(), - ), + preferredEmail: z.preprocess((val: string) => { + if (typeof val !== "string") return undefined; + if (val.trim() === "") return undefined; + return val; + }, z.email("Email address is invalid").optional()), }); export function useSettingsActions() { @@ -20,15 +21,28 @@ export function useSettingsActions() { .patch("users/me", { json: { name: data.name, - preferred_email: data.preferredEmail, + preferredEmail: data.preferredEmail, }, }) .json(); }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: authQueryKey }); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: useUserQueryKey }); }, }); - return { updateAccountInfo }; + const updateEmailConsent = useMutation({ + mutationFn: (selected: boolean) => { + return api + .patch("users/me/email-consent", { + json: { emailConsent: selected }, + }) + .json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: useUserQueryKey }); + }, + }); + + return { updateAccountInfo, updateEmailConsent }; } diff --git a/apps/web/src/routes/_protected/_attendee/hacker-portal.tsx b/apps/web/src/routes/_protected/_attendee/hacker-portal.tsx new file mode 100644 index 00000000..493a0d4a --- /dev/null +++ b/apps/web/src/routes/_protected/_attendee/hacker-portal.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_protected/_attendee/hacker-portal")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
TODO
; +} diff --git a/apps/web/src/routes/_protected/_attendee/layout.tsx b/apps/web/src/routes/_protected/_attendee/layout.tsx new file mode 100644 index 00000000..4fc9b6b2 --- /dev/null +++ b/apps/web/src/routes/_protected/_attendee/layout.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, notFound, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_protected/_attendee")({ + component: () => , + beforeLoad: ({ context }) => { + if (context.user.role !== "attendee") { + return notFound(); + } + }, +}); diff --git a/apps/web/src/routes/_protected/_user/application.tsx b/apps/web/src/routes/_protected/_user/application.tsx deleted file mode 100644 index ca729ae4..00000000 --- a/apps/web/src/routes/_protected/_user/application.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ErrorBoundary } from "react-error-boundary"; -import { ApplicationForm } from "@/modules/Application/ApplicationForm"; -import TablerAlertCircle from "~icons/tabler/alert-circle"; -import { useEffect } from "react"; -import { useHackathon } from "@/modules/Hackathon/hooks/useHackathon"; -import { Spinner } from "@/components/ui/Spinner"; - -export const Route = createFileRoute("/_protected/_user/application")({ - component: RouteComponent, -}); - -function RouteComponent() { - const hackathon = useHackathon(); - - // Show a confirmation dialog when the user closes the tab - useEffect(() => { - function beforeUnload(e: BeforeUnloadEvent) { - e.preventDefault(); - } - - window.addEventListener("beforeunload", beforeUnload); - - return () => { - window.removeEventListener("beforeunload", beforeUnload); - }; - }, []); - - // TODO: - // check early application and regular application - // - mark user application as early or regular. Do this in the backend - // if early application, show a message in the header to tell the user that they are submitting an early application - - if (hackathon.isLoading) { - return ( -
- -

Loading hackathon...

-
- ); - } - - if (!hackathon.data) { - return ( -
- -

Something went wrong while loading hackathon information :(

-
- ); - } - - if (new Date() > new Date(hackathon.data.applicationClose)) { - return ( -
-
- -

Applications have closed!

-
-
- ); - } - - return ( - - - - ); -} - -function Fallback() { - return ( -
- -

Something went wrong while loading application form :(

-
- ); -} diff --git a/apps/web/src/routes/_protected/_user/layout.tsx b/apps/web/src/routes/_protected/_user/layout.tsx deleted file mode 100644 index bb69785e..00000000 --- a/apps/web/src/routes/_protected/_user/layout.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { NavLink } from "@/components/AppShell/NavLink"; -import { - createFileRoute, - Outlet, - useLocation, - useRouter, -} from "@tanstack/react-router"; -import TablerCode from "~icons/tabler/code"; -import TablerClipboard from "~icons/tabler/clipboard"; -import { AppShell } from "@/components/AppShell/AppShell"; -import { PageLoading } from "@/components/PageLoading"; -import { Logo } from "@/components/Logo"; - -export const Route = createFileRoute("/_protected/_user")({ - pendingComponent: () => PageLoading(), - component: RouteComponent, -}); - -function RouteComponent() { - const router = useRouter(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - - return ( - - -
- router.navigate({ to: "/information" })} - className="py-2 cursor-pointer" - label="SwampHacks" - /> -
-
- - - } - active={pathname.startsWith("/information")} - /> - - } - active={pathname.startsWith("/application")} - /> - - - - - -
- ); -} diff --git a/apps/web/src/routes/_protected/application.tsx b/apps/web/src/routes/_protected/application.tsx new file mode 100644 index 00000000..c848356f --- /dev/null +++ b/apps/web/src/routes/_protected/application.tsx @@ -0,0 +1,306 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { ErrorBoundary } from "react-error-boundary"; +import { ApplicationForm } from "@/modules/Application/ApplicationForm"; +import TablerAlertCircle from "~icons/tabler/alert-circle"; +import { useEffect } from "react"; +import { hackathonQueryOptions } from "@/modules/Hackathon/hooks/useHackathon"; +import { api } from "@/lib/ky"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useUserQueryKey } from "@/lib/auth/hooks/useUser"; +import type { AuthUserResponse } from "@/lib/auth/types"; +import { myApplicationQueryOptions } from "@/modules/Application/hooks/useMyApplication"; +import { useApplicationActions } from "@/modules/Application/hooks/useApplicationActions"; +import { Button } from "@/components/ui/Button"; +import { PageLoading } from "@/components/PageLoading"; + +export const Route = createFileRoute("/_protected/application")({ + component: RouteComponent, + beforeLoad: ({ context }) => { + if (context.user.role === "attendee") { + throw redirect({ + to: "/hacker-portal", + }); + } + }, + pendingComponent: PageLoading, + loader: ({ context }) => { + return Promise.all([ + context.queryClient.ensureQueryData(hackathonQueryOptions()), + context.queryClient.ensureQueryData(myApplicationQueryOptions()), + ]); + }, +}); + +function RouteComponent() { + const { user } = Route.useRouteContext(); + const queryClient = useQueryClient(); + const hackathon = useSuspenseQuery(hackathonQueryOptions()); + const application = useSuspenseQuery(myApplicationQueryOptions()); + + // Show a confirmation dialog when the user closes the tab + // useEffect(() => { + // function beforeUnload(e: BeforeUnloadEvent) { + // e.preventDefault(); + // } + + // window.addEventListener("beforeunload", beforeUnload); + + // return () => { + // window.removeEventListener("beforeunload", beforeUnload); + // }; + // }, []); + + useEffect(() => { + if (user.hasSeenNewApplicationStatus === false) { + api.post(`users/me/acknowledge-new-application-status`); + + queryClient.setQueryData( + useUserQueryKey, + (oldData: AuthUserResponse) => ({ + ...oldData, + user: { + ...oldData.user, + hasSeenNewApplicationStatus: true, + }, + }), + ); + } + }, [user]); + + if (new Date() > new Date(hackathon.data.applicationClose)) { + return ( +
+
+ +

Applications have closed!

+
+
+ ); + } + + const applicationResponses = JSON.parse(atob(application.data.application)); + const name = applicationResponses["firstName"]; + + if (application.data.status === "accepted") { + return ; + } + + if (application.data.status === "rejected") { + return ; + } + + if (application.data.status === "waitlisted") { + return ; + } + + if (application.data.status === "withdrawn") { + return ; + } + + return ( + + + + ); +} + +function Fallback() { + return ( +
+ +

Something went wrong while loading application form :(

+
+ ); +} + +interface AcceptedProps { + name: string; +} + +function Accepted({ name }: AcceptedProps) { + const { confirmAttendance, withdrawApplication } = useApplicationActions(); + + const handleConfirmAttendance = async () => { + await confirmAttendance.mutateAsync(); + window.location.reload(); + }; + + const handleWithdrawApplication = async () => { + const isConfirmed = window.confirm( + "Are you sure you want to withdraw your application?", + ); + + if (isConfirmed) { + await withdrawApplication.mutateAsync(); + } + }; + + return ( +
+

Congrats, {name}! 🎉

+
+

You've been accepted to hack in SwampHacks XII!

+

+ Please confirm your attendance by January 2nd. Failure to do so means + you are giving up your spot, and we will admit someone from a + waitlist. +

+

+ If you're no longer able to attend, please withdraw your application + so we can offer your spot to another applicant and maintain an + accurate attendee count. +

+
+
+ + +
+
+ ); +} + +interface RejectedProps { + name: string; +} + +function Rejected({ name }: RejectedProps) { + return ( +
+

Hi, {name}!

+
+

+ We sincerely appreciate your interest in SwampHacks XII and the time + you took to apply. After careful consideration, we are unable to + accept you as a hacker at this time. +

+ +

+ However, we’d love to stay connected and invite you to get involved in + other ways: +

+ +
    +
  1. + 1. Join the Waitlist: We may have openings + available closer to the event. You can join the waitlist in the{" "} + + SwampHacks Portal + {" "} + or by signing up in person on the day of check-in if space allows. + The waitlist operates on a first-come, first-served basis. +
  2. +
  3. + 2. Mentor: Share your knowledge and guide hackers + through their projects.{" "} + + Sign up to be a mentor here + + . +
  4. +
  5. + 3. Volunteer: Help us run the event smoothly.{" "} + + Sign up to volunteer here + + . +
  6. +
+ +

+ If you have any questions, reach out in our{" "} + Discord server or + email us at{" "} + contact@swamphacks.com +

+
+
+ ); +} + +interface WaitlistedProps { + name: string; +} + +function Waitlisted({ name }: WaitlistedProps) { + return ( +
+

Hi, {name}!

+ +
+

+ Thank you for applying to SwampHacks XII! We were very impressed by + your application. At this time, we’re placing you on our{" "} + waitlist due to limited capacity. +

+ +

+ If spots open up, we’ll be sending out invitations on a rolling basis + leading up to the event. Waitlist decisions are made as space becomes + available. Please keep an eye out on your email or the hacker portal + for updates! +

+ +

+ If you have questions, feel free to reach out on our{" "} + Discord server or + email us at{" "} + contact@swamphacks.com +

+
+
+ ); +} + +interface WithdrawnProps { + name: string; +} + +function Withdrawn({ name }: WithdrawnProps) { + return ( +
+

Hi, {name}!

+ +
+

Your application for SwampHacks XII has been withdrawn.

+ +

+ We appreciate your interest in SwampHacks and the time you took to + apply. While we're sorry you won't be able to join us this year, we + hope to see you at a future event. +

+ +

+ If your plans change and registration is still open, please reach out + to our team and we'll do our best to help. +

+ +

+ You can also stay connected with the SwampHacks community through our{" "} + + Discord server + {" "} + and follow future announcements for upcoming events and opportunities. +

+ +

+ If you have any questions, feel free to contact us at{" "} + + contact@swamphacks.com + + . +

+
+
+ ); +} diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/layout.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/layout.tsx index 8670ebe2..a81b6e3a 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/layout.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/layout.tsx @@ -1,5 +1,5 @@ import AttendeeAppShell from "@/modules/Dashboard/AttendeeAppShell"; -import ApplicantAppShell from "@/modules/Dashboard/ApplicantAppShell"; +import ApplicantNavbar from "@/modules/Dashboard/ApplicantAppShell"; import StaffAppShell from "@/modules/Dashboard/StaffAppShell"; import { getUserEventRole } from "@/modules/Event/api/getUserEventRole"; import NotFoundPage from "@/modules/NotFound/NotFoundPage"; @@ -67,9 +67,9 @@ function RouteComponent() { ); case "applicant": return ( - + - + ); default: return
Unknown role: {eventRole}
; diff --git a/apps/web/src/routes/_protected/_user/information.tsx b/apps/web/src/routes/_protected/information.tsx similarity index 92% rename from apps/web/src/routes/_protected/_user/information.tsx rename to apps/web/src/routes/_protected/information.tsx index c9bc2461..6fff52af 100644 --- a/apps/web/src/routes/_protected/_user/information.tsx +++ b/apps/web/src/routes/_protected/information.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { OnboardingModal } from "@/modules/Onboarding/OnboardingModal"; import Cookies from "js-cookie"; -export const Route = createFileRoute("/_protected/_user/information")({ +export const Route = createFileRoute("/_protected/information")({ beforeLoad: (context) => { const { user } = context.context; const hasSkippedCookie = Cookies.get("welcome-modal-skipped") === "true"; diff --git a/apps/web/src/routes/_protected/layout.tsx b/apps/web/src/routes/_protected/layout.tsx index ebbe0094..b401fef2 100644 --- a/apps/web/src/routes/_protected/layout.tsx +++ b/apps/web/src/routes/_protected/layout.tsx @@ -1,20 +1,24 @@ -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { + createFileRoute, + Outlet, + redirect, + useLocation, + useRouter, +} from "@tanstack/react-router"; import { PageLoading } from "@/components/PageLoading"; +import ApplicantNavbar from "@/components/AppShell/ApplicantNavbar"; +import { AppShell } from "@/components/AppShell/AppShell"; +import AttendeeNavbar from "@/components/AppShell/AttendeeNavbar"; +import VisitorNavbar from "@/components/AppShell/VisitorNavbar"; +import { Logo } from "@/components/Logo"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { userQueryOptions } from "@/lib/auth/hooks/useUser"; // This layout component performs authentication checks before the user can access protected pages export const Route = createFileRoute("/_protected")({ beforeLoad: async ({ context, location }) => { const { user, error } = await context.userQuery.promise; - // Unauthenticated, return to login page - if (!user && !error) { - console.log("User is not authenticated, redirecting to login."); - throw redirect({ - to: "/", - search: { redirect: location.pathname }, - }); - } - if (error) { // TODO: Display a friendly error to the user? console.error("Auth error in beforeLoad in layout.tsx:", error); @@ -26,14 +30,67 @@ export const Route = createFileRoute("/_protected")({ }); } + // Unauthenticated, return to login page + if (!user) { + console.log("User is not authenticated, redirecting to login."); + throw redirect({ + to: "/", + search: { redirect: location.pathname }, + }); + } + // Return user data for use in the route loader or component return { user }; }, - pendingMs: 1000, - pendingComponent: () => PageLoading(), + pendingComponent: PageLoading, component: RouteComponent, }); function RouteComponent() { - return ; + const router = useRouter(); + const pathname = useLocation({ select: (loc) => loc.pathname }); + const { data } = useSuspenseQuery(userQueryOptions()); + const user = data.user!; + + const renderNavbarBasedOnRole = () => { + switch (user.role) { + case "visitor": + return ; + case "applicant": + return ( + + ); + case "attendee": + return ; + case "admin": + break; + case "staff": + break; + default: + break; + } + }; + + return ( + + +
+ router.navigate({ to: "/information" })} + className="py-2 cursor-pointer" + label="SwampHacks XII" + /> +
+
+ + {renderNavbarBasedOnRole()} + + + + +
+ ); } diff --git a/apps/web/src/routes/_protected/settings.tsx b/apps/web/src/routes/_protected/settings.tsx index c60a532b..19a8b2b3 100644 --- a/apps/web/src/routes/_protected/settings.tsx +++ b/apps/web/src/routes/_protected/settings.tsx @@ -1,6 +1,8 @@ import { SettingsPage } from "@/modules/Settings/SettingsPage"; import { auth } from "@/lib/authClient"; import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { userQueryOptions } from "@/lib/auth/hooks/useUser"; +import { useSuspenseQuery } from "@tanstack/react-query"; export const Route = createFileRoute("/_protected/settings")({ component: RouteComponent, @@ -8,6 +10,9 @@ export const Route = createFileRoute("/_protected/settings")({ function RouteComponent() { const router = useRouter(); + // We call useSuspenseQuery to get user data because they can change their information and + // this hook lets us subscribe to it and rerender whenever a change happens + const user = useSuspenseQuery(userQueryOptions()); const logout = async () => { await auth.logOut(); @@ -16,7 +21,7 @@ function RouteComponent() { return (
- +
); } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 1b747c29..b1313390 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute("/")({ if (user) { console.log("User is already authenticated, redirecting to portal."); throw redirect({ - to: "/portal", + to: "/information", }); } },