From d821e7923dc811705b7a23d66ed2c33ba4731a6d Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 19:58:58 -0500 Subject: [PATCH 01/17] refactor: extract StringArray to types.go and add ApplicationSchemaField to settings Move StringArray type to its own file since it's shared by schedule.go. Add ApplicationSchemaField struct, SettingsKeyApplicationSchema constant, and GetApplicationSchema/UpdateApplicationSchema methods to settings store. --- internal/store/applications.go | 49 --------------------------- internal/store/mock_store.go | 13 +++++++ internal/store/settings.go | 62 ++++++++++++++++++++++++++++++++++ internal/store/storage.go | 2 ++ internal/store/types.go | 55 ++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 49 deletions(-) create mode 100644 internal/store/types.go diff --git a/internal/store/applications.go b/internal/store/applications.go index f310af7c..99ccdc4b 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -3,7 +3,6 @@ package store import ( "context" "database/sql" - "database/sql/driver" "encoding/base64" "encoding/json" "errors" @@ -12,54 +11,6 @@ import ( "time" ) -// StringArray implements sql.Scanner and driver.Valuer for PostgreSQL text[] columns. -type StringArray []string - -func (a *StringArray) Scan(src any) error { - if src == nil { - *a = nil - return nil - } - s, ok := src.(string) - if !ok { - if b, ok2 := src.([]byte); ok2 { - s = string(b) - } else { - return fmt.Errorf("StringArray.Scan: unsupported type %T", src) - } - } - s = strings.TrimSpace(s) - if s == "{}" || s == "" { - *a = StringArray{} - return nil - } - // Strip outer braces: {item1,item2} -> item1,item2 - s = s[1 : len(s)-1] - parts := strings.Split(s, ",") - result := make([]string, len(parts)) - for i, p := range parts { - // Strip surrounding quotes if present - p = strings.TrimSpace(p) - if len(p) >= 2 && p[0] == '"' && p[len(p)-1] == '"' { - p = p[1 : len(p)-1] - } - result[i] = p - } - *a = result - return nil -} - -func (a StringArray) Value() (driver.Value, error) { - if a == nil { - return nil, nil - } - parts := make([]string, len(a)) - for i, s := range a { - parts[i] = `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` - } - return "{" + strings.Join(parts, ",") + "}", nil -} - type ApplicationStatus string const ( diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index 576e7b42..4ba574e2 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -163,6 +163,19 @@ func (m *MockSettingsStore) UpdateShortAnswerQuestions(ctx context.Context, ques return args.Error(0) } +func (m *MockSettingsStore) GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]ApplicationSchemaField), args.Error(1) +} + +func (m *MockSettingsStore) UpdateApplicationSchema(ctx context.Context, fields []ApplicationSchemaField) error { + args := m.Called(fields) + return args.Error(0) +} + func (m *MockSettingsStore) GetReviewsPerApplication(ctx context.Context) (int, error) { args := m.Called() return args.Int(0), args.Error(1) diff --git a/internal/store/settings.go b/internal/store/settings.go index c0cb8b57..b8f6a382 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -21,6 +21,7 @@ type SettingsStore struct { } const SettingsKeyShortAnswerQuestions = "short_answer_questions" +const SettingsKeyApplicationSchema = "application_schema" const SettingsKeyReviewsPerApplication = "reviews_per_application" const SettingsKeyReviewAssignmentToggle = "review_assignment_toggle" const SettingsKeyScanTypes = "scan_types" @@ -34,6 +35,19 @@ type HackathonDateRange struct { EndDate *string `json:"end_date"` } +// ApplicationSchemaField defines a single field in the configurable application form. +// The full schema is stored as a JSON array in the settings table under key "application_schema". +type ApplicationSchemaField struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Required bool `json:"required"` + Section string `json:"section,omitempty"` + DisplayOrder int `json:"display_order"` + Options []string `json:"options,omitempty"` + Validation map[string]interface{} `json:"validation,omitempty"` +} + // ReviewAssignmentEntry represents a single admin's review assignment toggle state. // Used in the review_assignment_toggle settings JSON array. type ReviewAssignmentEntry struct { @@ -69,6 +83,54 @@ func (s *SettingsStore) GetShortAnswerQuestions(ctx context.Context) ([]ShortAns return questions, nil } +// GetApplicationSchema returns the parsed application form schema fields +func (s *SettingsStore) GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT value + FROM settings + WHERE key = $1 + ` + + var value []byte + err := s.db.QueryRowContext(ctx, query, SettingsKeyApplicationSchema).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []ApplicationSchemaField{}, nil + } + return nil, err + } + + var fields []ApplicationSchemaField + if err := json.Unmarshal(value, &fields); err != nil { + return nil, err + } + + return fields, nil +} + +// UpdateApplicationSchema replaces the application form schema with the provided fields +func (s *SettingsStore) UpdateApplicationSchema(ctx context.Context, fields []ApplicationSchemaField) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + value, err := json.Marshal(fields) + if err != nil { + return err + } + + query := ` + INSERT INTO settings (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + ` + + _, err = s.db.ExecContext(ctx, query, SettingsKeyApplicationSchema, string(value)) + return err +} + // GetReviewsPerApplication returns the configured number of reviews per application func (s *SettingsStore) GetReviewsPerApplication(ctx context.Context) (int, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) diff --git a/internal/store/storage.go b/internal/store/storage.go index 802988b6..be39a3d1 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -41,6 +41,8 @@ type Storage struct { Settings interface { GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error) UpdateShortAnswerQuestions(ctx context.Context, questions []ShortAnswerQuestion) error + GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) + UpdateApplicationSchema(ctx context.Context, fields []ApplicationSchemaField) error GetReviewsPerApplication(ctx context.Context) (int, error) SetReviewsPerApplication(ctx context.Context, value int) error GetAllReviewAssignmentToggles(ctx context.Context) ([]ReviewAssignmentEntry, error) diff --git a/internal/store/types.go b/internal/store/types.go new file mode 100644 index 00000000..27411375 --- /dev/null +++ b/internal/store/types.go @@ -0,0 +1,55 @@ +package store + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +// StringArray implements sql.Scanner and driver.Valuer for PostgreSQL text[] columns. +type StringArray []string + +func (a *StringArray) Scan(src any) error { + if src == nil { + *a = nil + return nil + } + s, ok := src.(string) + if !ok { + if b, ok2 := src.([]byte); ok2 { + s = string(b) + } else { + return fmt.Errorf("StringArray.Scan: unsupported type %T", src) + } + } + s = strings.TrimSpace(s) + if s == "{}" || s == "" { + *a = StringArray{} + return nil + } + // Strip outer braces: {item1,item2} -> item1,item2 + s = s[1 : len(s)-1] + parts := strings.Split(s, ",") + result := make([]string, len(parts)) + for i, p := range parts { + // Strip surrounding quotes if present + p = strings.TrimSpace(p) + if len(p) >= 2 && p[0] == '"' && p[len(p)-1] == '"' { + p = p[1 : len(p)-1] + } + result[i] = p + } + *a = result + return nil +} + +func (a StringArray) Value() (driver.Value, error) { + if a == nil { + return nil, nil + } + parts := make([]string, len(a)) + for i, s := range a { + parts[i] = `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return "{" + strings.Join(parts, ",") + "}", nil +} From 423c7152665a84fe7b347c4f32d0fb838c55b662 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:02:20 -0500 Subject: [PATCH 02/17] refactor: rewrite Application struct and store methods for JSONB responses Replace ~25 individual form-field columns with a single `responses JSONB` column. Simplify Create/Update/List/SetStatus/GetEmailsByStatus queries. Add scanApplication helper and applicationSelectCols constant for DRY. --- internal/store/applications.go | 231 ++++++++------------------------- 1 file changed, 55 insertions(+), 176 deletions(-) diff --git a/internal/store/applications.go b/internal/store/applications.go index 99ccdc4b..12c33450 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -21,21 +21,6 @@ const ( StatusWaitlisted ApplicationStatus = "waitlisted" ) -type DietaryRestriction string - -const ( - DietaryVegan DietaryRestriction = "vegan" - DietaryVegetarian DietaryRestriction = "vegetarian" - DietaryHalal DietaryRestriction = "halal" - DietaryNuts DietaryRestriction = "nuts" - DietaryFish DietaryRestriction = "fish" - DietaryWheat DietaryRestriction = "wheat" - DietaryDairy DietaryRestriction = "dairy" - DietaryEggs DietaryRestriction = "eggs" - DietaryNoBeef DietaryRestriction = "no_beef" - DietaryNoPork DietaryRestriction = "no_pork" -) - // PaginationDirection for bidirectional cursor traversal type PaginationDirection string @@ -154,91 +139,54 @@ type Application struct { UserID string `json:"user_id"` Status ApplicationStatus `json:"status"` - FirstName *string `json:"first_name" validate:"omitempty,min=1"` - LastName *string `json:"last_name" validate:"omitempty,min=1"` - PhoneE164 *string `json:"phone_e164" validate:"omitempty,e164"` - Age *int16 `json:"age" validate:"omitempty,min=1,max=150"` - - CountryOfResidence *string `json:"country_of_residence" validate:"omitempty,min=1"` - Gender *string `json:"gender" validate:"omitempty,min=1"` - Race *string `json:"race" validate:"omitempty,min=1"` - Ethnicity *string `json:"ethnicity" validate:"omitempty,min=1"` - - University *string `json:"university" validate:"omitempty,min=1"` - Major *string `json:"major" validate:"omitempty,min=1"` - LevelOfStudy *string `json:"level_of_study" validate:"omitempty,min=1"` - - ShortAnswerResponses json.RawMessage `json:"short_answer_responses"` - - HackathonsAttendedCount *int16 `json:"hackathons_attended_count" validate:"omitempty,min=0"` - SoftwareExperienceLevel *string `json:"software_experience_level" validate:"omitempty,min=1"` - HeardAbout *string `json:"heard_about" validate:"omitempty,min=1"` - - ShirtSize *string `json:"shirt_size" validate:"omitempty,min=1"` - DietaryRestrictions []string `json:"dietary_restrictions"` - Accommodations *string `json:"accommodations"` - - Github *string `json:"github" validate:"omitempty,url"` - LinkedIn *string `json:"linkedin" validate:"omitempty,url"` - Website *string `json:"website" validate:"omitempty,url"` - ResumePath *string `json:"resume_path"` + Responses json.RawMessage `json:"responses"` + ResumePath *string `json:"resume_path"` + AIPercent *int16 `json:"ai_percent"` - AckApplication bool `json:"ack_application"` AckMLHCOC bool `json:"ack_mlh_coc"` AckMLHPrivacy bool `json:"ack_mlh_privacy"` OptInMLHEmails bool `json:"opt_in_mlh_emails"` - SubmittedAt *time.Time `json:"submitted_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - AcceptVotes int `json:"accept_votes"` RejectVotes int `json:"reject_votes"` WaitlistVotes int `json:"waitlist_votes"` ReviewsAssigned int `json:"reviews_assigned"` ReviewsCompleted int `json:"reviews_completed"` - AIPercent *int16 `json:"ai_percent"` + SubmittedAt *time.Time `json:"submitted_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ApplicationsStore struct { db *sql.DB } +// applicationSelectCols is the standard SELECT for loading a full Application +const applicationSelectCols = ` + id, user_id, status, responses, resume_path, ai_percent, + ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, + accept_votes, reject_votes, waitlist_votes, reviews_assigned, reviews_completed, + submitted_at, created_at, updated_at` + +// scanApplication scans a row into an Application struct +func scanApplication(row interface{ Scan(dest ...any) error }, app *Application) error { + return row.Scan( + &app.ID, &app.UserID, &app.Status, &app.Responses, &app.ResumePath, &app.AIPercent, + &app.AckMLHCOC, &app.AckMLHPrivacy, &app.OptInMLHEmails, + &app.AcceptVotes, &app.RejectVotes, &app.WaitlistVotes, &app.ReviewsAssigned, &app.ReviewsCompleted, + &app.SubmittedAt, &app.CreatedAt, &app.UpdatedAt, + ) +} + func (s *ApplicationsStore) GetByID(ctx context.Context, id string) (*Application, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() - query := ` - SELECT id, user_id, status, - first_name, last_name, phone_e164, age, - country_of_residence, gender, race, ethnicity, - university, major, level_of_study, - short_answer_responses, - hackathons_attended_count, software_experience_level, heard_about, - shirt_size, dietary_restrictions, accommodations, - github, linkedin, website, resume_path, - ack_application, ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, - submitted_at, created_at, updated_at, - accept_votes, reject_votes, waitlist_votes, reviews_assigned, reviews_completed, ai_percent - FROM applications - WHERE id = $1 - ` + query := `SELECT ` + applicationSelectCols + ` FROM applications WHERE id = $1` var app Application - err := s.db.QueryRowContext(ctx, query, id).Scan( - &app.ID, &app.UserID, &app.Status, - &app.FirstName, &app.LastName, &app.PhoneE164, &app.Age, - &app.CountryOfResidence, &app.Gender, &app.Race, &app.Ethnicity, - &app.University, &app.Major, &app.LevelOfStudy, - &app.ShortAnswerResponses, - &app.HackathonsAttendedCount, &app.SoftwareExperienceLevel, &app.HeardAbout, - &app.ShirtSize, (*StringArray)(&app.DietaryRestrictions), &app.Accommodations, - &app.Github, &app.LinkedIn, &app.Website, &app.ResumePath, - &app.AckApplication, &app.AckMLHCOC, &app.AckMLHPrivacy, &app.OptInMLHEmails, - &app.SubmittedAt, &app.CreatedAt, &app.UpdatedAt, - &app.AcceptVotes, &app.RejectVotes, &app.WaitlistVotes, &app.ReviewsAssigned, &app.ReviewsCompleted, &app.AIPercent, - ) + err := scanApplication(s.db.QueryRowContext(ctx, query, id), &app) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -253,36 +201,10 @@ func (s *ApplicationsStore) GetByUserID(ctx context.Context, userID string) (*Ap ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() - query := ` - SELECT id, user_id, status, - first_name, last_name, phone_e164, age, - country_of_residence, gender, race, ethnicity, - university, major, level_of_study, - short_answer_responses, - hackathons_attended_count, software_experience_level, heard_about, - shirt_size, dietary_restrictions, accommodations, - github, linkedin, website, resume_path, - ack_application, ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, - submitted_at, created_at, updated_at, - accept_votes, reject_votes, waitlist_votes, reviews_assigned, reviews_completed - FROM applications - WHERE user_id = $1 - ` + query := `SELECT ` + applicationSelectCols + ` FROM applications WHERE user_id = $1` var app Application - err := s.db.QueryRowContext(ctx, query, userID).Scan( - &app.ID, &app.UserID, &app.Status, - &app.FirstName, &app.LastName, &app.PhoneE164, &app.Age, - &app.CountryOfResidence, &app.Gender, &app.Race, &app.Ethnicity, - &app.University, &app.Major, &app.LevelOfStudy, - &app.ShortAnswerResponses, - &app.HackathonsAttendedCount, &app.SoftwareExperienceLevel, &app.HeardAbout, - &app.ShirtSize, (*StringArray)(&app.DietaryRestrictions), &app.Accommodations, - &app.Github, &app.LinkedIn, &app.Website, &app.ResumePath, - &app.AckApplication, &app.AckMLHCOC, &app.AckMLHPrivacy, &app.OptInMLHEmails, - &app.SubmittedAt, &app.CreatedAt, &app.UpdatedAt, - &app.AcceptVotes, &app.RejectVotes, &app.WaitlistVotes, &app.ReviewsAssigned, &app.ReviewsCompleted, - ) + err := scanApplication(s.db.QueryRowContext(ctx, query, userID), &app) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -300,14 +222,14 @@ func (s *ApplicationsStore) Create(ctx context.Context, app *Application) error query := ` INSERT INTO applications (user_id) VALUES ($1) - RETURNING id, status, short_answer_responses, dietary_restrictions, - ack_application, ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, + RETURNING id, status, responses, + ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, created_at, updated_at ` err := s.db.QueryRowContext(ctx, query, app.UserID).Scan( - &app.ID, &app.Status, &app.ShortAnswerResponses, (*StringArray)(&app.DietaryRestrictions), - &app.AckApplication, &app.AckMLHCOC, &app.AckMLHPrivacy, &app.OptInMLHEmails, + &app.ID, &app.Status, &app.Responses, + &app.AckMLHCOC, &app.AckMLHPrivacy, &app.OptInMLHEmails, &app.CreatedAt, &app.UpdatedAt, ) if err != nil { @@ -326,47 +248,19 @@ func (s *ApplicationsStore) Update(ctx context.Context, app *Application) error query := ` UPDATE applications SET - first_name = $2, - last_name = $3, - phone_e164 = $4, - age = $5, - country_of_residence = $6, - gender = $7, - race = $8, - ethnicity = $9, - university = $10, - major = $11, - level_of_study = $12, - short_answer_responses = $13, - hackathons_attended_count = $14, - software_experience_level = $15, - heard_about = $16, - shirt_size = $17, - dietary_restrictions = $18, - accommodations = $19, - github = $20, - linkedin = $21, - website = $22, - resume_path = $27, - ack_application = $23, - ack_mlh_coc = $24, - ack_mlh_privacy = $25, - opt_in_mlh_emails = $26 + responses = $2, + resume_path = $3, + ack_mlh_coc = $4, + ack_mlh_privacy = $5, + opt_in_mlh_emails = $6 WHERE id = $1 RETURNING updated_at ` err := s.db.QueryRowContext(ctx, query, app.ID, - app.FirstName, app.LastName, app.PhoneE164, app.Age, - app.CountryOfResidence, app.Gender, app.Race, app.Ethnicity, - app.University, app.Major, app.LevelOfStudy, - app.ShortAnswerResponses, - app.HackathonsAttendedCount, app.SoftwareExperienceLevel, app.HeardAbout, - app.ShirtSize, StringArray(app.DietaryRestrictions), app.Accommodations, - app.Github, app.LinkedIn, app.Website, - app.AckApplication, app.AckMLHCOC, app.AckMLHPrivacy, app.OptInMLHEmails, - app.ResumePath, + app.Responses, app.ResumePath, + app.AckMLHCOC, app.AckMLHPrivacy, app.OptInMLHEmails, ).Scan(&app.UpdatedAt) if err != nil { @@ -473,10 +367,16 @@ func (s *ApplicationsStore) List( selectCols := ` SELECT a.id, a.user_id, u.email, a.status, - a.first_name, a.last_name, a.phone_e164, a.age, - a.country_of_residence, a.gender, - a.university, a.major, a.level_of_study, - a.hackathons_attended_count, + a.responses->>'first_name' AS first_name, + a.responses->>'last_name' AS last_name, + a.responses->>'phone_e164' AS phone_e164, + (a.responses->>'age')::smallint AS age, + a.responses->>'country_of_residence' AS country_of_residence, + a.responses->>'gender' AS gender, + a.responses->>'university' AS university, + a.responses->>'major' AS major, + a.responses->>'level_of_study' AS level_of_study, + (a.responses->>'hackathons_attended_count')::smallint AS hackathons_attended_count, a.submitted_at, a.created_at, a.updated_at, a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent, a.resume_path IS NOT NULL AS has_resume @@ -485,8 +385,8 @@ func (s *ApplicationsStore) List( searchClause := `AND ($5::text IS NULL OR ( u.email ILIKE '%' || $5 || '%' - OR a.first_name ILIKE '%' || $5 || '%' - OR a.last_name ILIKE '%' || $5 || '%' + OR a.responses->>'first_name' ILIKE '%' || $5 || '%' + OR a.responses->>'last_name' ILIKE '%' || $5 || '%' ))` // Fetch limit+1 to determine hasMore @@ -647,33 +547,10 @@ func (s *ApplicationsStore) SetStatus(ctx context.Context, id string, status App UPDATE applications SET status = $2, updated_at = NOW() WHERE id = $1 - RETURNING id, user_id, status, - first_name, last_name, phone_e164, age, - country_of_residence, gender, race, ethnicity, - university, major, level_of_study, - short_answer_responses, - hackathons_attended_count, software_experience_level, heard_about, - shirt_size, dietary_restrictions, accommodations, - github, linkedin, website, resume_path, - ack_application, ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, - submitted_at, created_at, updated_at, - accept_votes, reject_votes, waitlist_votes, reviews_assigned, reviews_completed - ` + RETURNING ` + applicationSelectCols var app Application - err := s.db.QueryRowContext(ctx, query, id, status).Scan( - &app.ID, &app.UserID, &app.Status, - &app.FirstName, &app.LastName, &app.PhoneE164, &app.Age, - &app.CountryOfResidence, &app.Gender, &app.Race, &app.Ethnicity, - &app.University, &app.Major, &app.LevelOfStudy, - &app.ShortAnswerResponses, - &app.HackathonsAttendedCount, &app.SoftwareExperienceLevel, &app.HeardAbout, - &app.ShirtSize, (*StringArray)(&app.DietaryRestrictions), &app.Accommodations, - &app.Github, &app.LinkedIn, &app.Website, &app.ResumePath, - &app.AckApplication, &app.AckMLHCOC, &app.AckMLHPrivacy, &app.OptInMLHEmails, - &app.SubmittedAt, &app.CreatedAt, &app.UpdatedAt, - &app.AcceptVotes, &app.RejectVotes, &app.WaitlistVotes, &app.ReviewsAssigned, &app.ReviewsCompleted, - ) + err := scanApplication(s.db.QueryRowContext(ctx, query, id, status), &app) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -734,7 +611,9 @@ func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status Applic defer cancel() query := ` - SELECT a.user_id, u.email, a.first_name, a.last_name + SELECT a.user_id, u.email, + a.responses->>'first_name' AS first_name, + a.responses->>'last_name' AS last_name FROM applications a INNER JOIN users u ON a.user_id = u.id WHERE a.status = $1 From a95b75141079d7f85a0346813e006a4f32e2cd27 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:03:11 -0500 Subject: [PATCH 03/17] refactor: update reviews SQL to extract application fields from JSONB Change GetPendingByAdminID and GetCompletedByAdminID queries to use a.responses->>'field_name' instead of direct column references. --- internal/store/reviews.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/store/reviews.go b/internal/store/reviews.go index c3dad089..8a2deb14 100644 --- a/internal/store/reviews.go +++ b/internal/store/reviews.go @@ -96,8 +96,11 @@ func (s *ApplicationReviewsStore) GetPendingByAdminID(ctx context.Context, admin SELECT ar.id, ar.application_id, ar.admin_id, ar.vote, ar.notes, ar.assigned_at, ar.reviewed_at, ar.created_at, ar.updated_at, - a.first_name, a.last_name, u.email, a.age, - a.university, a.major, a.country_of_residence, a.hackathons_attended_count + a.responses->>'first_name', a.responses->>'last_name', u.email, + (a.responses->>'age')::smallint, + a.responses->>'university', a.responses->>'major', + a.responses->>'country_of_residence', + (a.responses->>'hackathons_attended_count')::smallint FROM application_reviews ar JOIN applications a ON ar.application_id = a.id JOIN users u ON a.user_id = u.id @@ -144,8 +147,11 @@ func (s *ApplicationReviewsStore) GetCompletedByAdminID(ctx context.Context, adm SELECT ar.id, ar.application_id, ar.admin_id, ar.vote, ar.notes, ar.assigned_at, ar.reviewed_at, ar.created_at, ar.updated_at, - a.first_name, a.last_name, u.email, a.age, - a.university, a.major, a.country_of_residence, a.hackathons_attended_count + a.responses->>'first_name', a.responses->>'last_name', u.email, + (a.responses->>'age')::smallint, + a.responses->>'university', a.responses->>'major', + a.responses->>'country_of_residence', + (a.responses->>'hackathons_attended_count')::smallint FROM application_reviews ar JOIN applications a ON ar.application_id = a.id JOIN users u ON a.user_id = u.id From e86d07f19f025b673cd34298261e7787bfe900b4 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:04:30 -0500 Subject: [PATCH 04/17] refactor: rewrite application handlers for JSONB responses Replace 25-field UpdateApplicationPayload with 5 fields (responses, resume_path, ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails). Switch to schema-driven validation on submit using GetApplicationSchema. Rename ApplicationWithQuestions to ApplicationWithSchema. --- cmd/api/applications.go | 246 +++++++++------------------------------- 1 file changed, 56 insertions(+), 190 deletions(-) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index a31035b4..6931751c 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -13,45 +13,17 @@ import ( ) type UpdateApplicationPayload struct { - FirstName *string `json:"first_name" validate:"omitempty,min=1"` - LastName *string `json:"last_name" validate:"omitempty,min=1"` - PhoneE164 *string `json:"phone_e164" validate:"omitempty,e164"` - Age *int16 `json:"age" validate:"omitempty,min=1,max=150"` - - CountryOfResidence *string `json:"country_of_residence" validate:"omitempty,min=1"` - Gender *string `json:"gender" validate:"omitempty,min=1"` - Race *string `json:"race" validate:"omitempty,min=1"` - Ethnicity *string `json:"ethnicity" validate:"omitempty,min=1"` - - University *string `json:"university" validate:"omitempty,min=1"` - Major *string `json:"major" validate:"omitempty,min=1"` - LevelOfStudy *string `json:"level_of_study" validate:"omitempty,min=1"` - - ShortAnswerResponses json.RawMessage `json:"short_answer_responses"` - - HackathonsAttendedCount *int16 `json:"hackathons_attended_count" validate:"omitempty,min=0"` - SoftwareExperienceLevel *string `json:"software_experience_level" validate:"omitempty,min=1"` - HeardAbout *string `json:"heard_about" validate:"omitempty,min=1"` - - ShirtSize *string `json:"shirt_size" validate:"omitempty,min=1"` - DietaryRestrictions *[]string `json:"dietary_restrictions"` - Accommodations *string `json:"accommodations"` - - Github *string `json:"github" validate:"omitempty,url"` - LinkedIn *string `json:"linkedin" validate:"omitempty,url"` - Website *string `json:"website" validate:"omitempty,url"` - ResumePath *string `json:"resume_path"` - - AckApplication *bool `json:"ack_application"` - AckMLHCOC *bool `json:"ack_mlh_coc"` - AckMLHPrivacy *bool `json:"ack_mlh_privacy"` - OptInMLHEmails *bool `json:"opt_in_mlh_emails"` + Responses json.RawMessage `json:"responses"` + ResumePath *string `json:"resume_path"` + AckMLHCOC *bool `json:"ack_mlh_coc"` + AckMLHPrivacy *bool `json:"ack_mlh_privacy"` + OptInMLHEmails *bool `json:"opt_in_mlh_emails"` } -// SAQs embeds questions in the response for the hacker -type ApplicationWithQuestions struct { +// ApplicationWithSchema embeds the schema in the response for the hacker +type ApplicationWithSchema struct { *store.Application - ShortAnswerQuestions []store.ShortAnswerQuestion `json:"short_answer_questions"` + ApplicationSchema []store.ApplicationSchemaField `json:"application_schema"` } // getOrCreateApplicationHandler returns or creates the user's hackathon application @@ -61,7 +33,7 @@ type ApplicationWithQuestions struct { // @Tags hackers // @Accept json // @Produce json -// @Success 200 {object} store.Application +// @Success 200 {object} ApplicationWithSchema // @Failure 401 {object} object{error=string} // @Failure 500 {object} object{error=string} // @Security CookieAuth @@ -97,17 +69,16 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * } } - // Fetch questions to embed in response - questions, err := app.store.Settings.GetShortAnswerQuestions(r.Context()) + // Fetch schema to embed in response + schema, err := app.store.Settings.GetApplicationSchema(r.Context()) if err != nil { app.internalServerError(w, r, err) return } - // Return application with embedded questions - response := ApplicationWithQuestions{ - Application: application, - ShortAnswerQuestions: questions, + response := ApplicationWithSchema{ + Application: application, + ApplicationSchema: schema, } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -158,81 +129,13 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. return } - if err := Validate.Struct(req); err != nil { - app.badRequestResponse(w, r, err) - return - } - - // only update if pointer is not nil - if req.FirstName != nil { - application.FirstName = req.FirstName - } - if req.LastName != nil { - application.LastName = req.LastName - } - if req.PhoneE164 != nil { - application.PhoneE164 = req.PhoneE164 - } - if req.Age != nil { - application.Age = req.Age - } - if req.CountryOfResidence != nil { - application.CountryOfResidence = req.CountryOfResidence - } - if req.Gender != nil { - application.Gender = req.Gender - } - if req.Race != nil { - application.Race = req.Race - } - if req.Ethnicity != nil { - application.Ethnicity = req.Ethnicity - } - if req.University != nil { - application.University = req.University - } - if req.Major != nil { - application.Major = req.Major - } - if req.LevelOfStudy != nil { - application.LevelOfStudy = req.LevelOfStudy - } - if req.ShortAnswerResponses != nil { - application.ShortAnswerResponses = req.ShortAnswerResponses - } - if req.HackathonsAttendedCount != nil { - application.HackathonsAttendedCount = req.HackathonsAttendedCount - } - if req.SoftwareExperienceLevel != nil { - application.SoftwareExperienceLevel = req.SoftwareExperienceLevel - } - if req.HeardAbout != nil { - application.HeardAbout = req.HeardAbout - } - if req.ShirtSize != nil { - application.ShirtSize = req.ShirtSize - } - if req.DietaryRestrictions != nil { - application.DietaryRestrictions = *req.DietaryRestrictions - } - if req.Accommodations != nil { - application.Accommodations = req.Accommodations - } - if req.Github != nil { - application.Github = req.Github - } - if req.LinkedIn != nil { - application.LinkedIn = req.LinkedIn - } - if req.Website != nil { - application.Website = req.Website + // Only update if field is present in the request + if req.Responses != nil { + application.Responses = req.Responses } if req.ResumePath != nil { application.ResumePath = req.ResumePath } - if req.AckApplication != nil { - application.AckApplication = *req.AckApplication - } if req.AckMLHCOC != nil { application.AckMLHCOC = *req.AckMLHCOC } @@ -256,7 +159,7 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. // submitApplicationHandler submits the authenticated user's application for review // // @Summary Submit application -// @Description Submits the authenticated user's application for review. All required fields must be filled and acknowledgments must be accepted. Application must be in draft status. +// @Description Submits the authenticated user's application for review. All required schema fields must be filled and acknowledgments must be accepted. Application must be in draft status. // @Tags hackers // @Produce json // @Success 200 {object} store.Application @@ -288,85 +191,35 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. return } - // Validate required fields - var missing []string - - if application.FirstName == nil { - missing = append(missing, "first_name") - } - if application.LastName == nil { - missing = append(missing, "last_name") - } - if application.PhoneE164 == nil { - missing = append(missing, "phone_e164") - } - if application.Age == nil { - missing = append(missing, "age") - } - if application.CountryOfResidence == nil { - missing = append(missing, "country_of_residence") - } - if application.Gender == nil { - missing = append(missing, "gender") - } - if application.Race == nil { - missing = append(missing, "race") - } - if application.Ethnicity == nil { - missing = append(missing, "ethnicity") - } - if application.University == nil { - missing = append(missing, "university") - } - if application.Major == nil { - missing = append(missing, "major") - } - if application.LevelOfStudy == nil { - missing = append(missing, "level_of_study") - } - - // Validate dynamic short answer questions - questions, err := app.store.Settings.GetShortAnswerQuestions(r.Context()) + // Fetch the application schema for validation + schema, err := app.store.Settings.GetApplicationSchema(r.Context()) if err != nil { app.internalServerError(w, r, err) return } - var responses map[string]string - if application.ShortAnswerResponses != nil { - if err := json.Unmarshal(application.ShortAnswerResponses, &responses); err != nil { - responses = make(map[string]string) + // Parse responses for validation + var responses map[string]interface{} + if application.Responses != nil { + if err := json.Unmarshal(application.Responses, &responses); err != nil { + responses = make(map[string]interface{}) } } else { - responses = make(map[string]string) + responses = make(map[string]interface{}) } - for _, q := range questions { - if q.Required { - answer, exists := responses[q.ID] - if !exists || strings.TrimSpace(answer) == "" { - missing = append(missing, "short_answer:"+q.ID) + // Validate required schema fields + var missing []string + for _, field := range schema { + if field.Required { + val, exists := responses[field.ID] + if !exists || isEmpty(val) { + missing = append(missing, field.ID) } } } - if application.HackathonsAttendedCount == nil { - missing = append(missing, "hackathons_attended_count") - } - if application.SoftwareExperienceLevel == nil { - missing = append(missing, "software_experience_level") - } - if application.HeardAbout == nil { - missing = append(missing, "heard_about") - } - if application.ShirtSize == nil { - missing = append(missing, "shirt_size") - } - // Validate acknowledgments - if !application.AckApplication { - missing = append(missing, "ack_application") - } if !application.AckMLHCOC { missing = append(missing, "ack_mlh_coc") } @@ -390,6 +243,21 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. } } +// isEmpty checks if a response value is considered empty +func isEmpty(val interface{}) bool { + if val == nil { + return true + } + switch v := val.(type) { + case string: + return strings.TrimSpace(v) == "" + case []interface{}: + return len(v) == 0 + default: + return false + } +} + // getApplicationStatsHandler returns aggregated statistics for all applications // // @Summary Get application stats (Admin) @@ -588,14 +456,14 @@ func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Requ } } -// getApplication returns a single application by ID with embedded questions +// getApplication returns a single application by ID with embedded schema // // @Summary Get application by ID (Admin) -// @Description Returns a single application by its ID with embedded short answer questions +// @Description Returns a single application by its ID with embedded application schema // @Tags admin/applications // @Produce json // @Param applicationID path string true "Application ID" -// @Success 200 {object} ApplicationWithQuestions +// @Success 200 {object} ApplicationWithSchema // @Failure 400 {object} object{error=string} // @Failure 401 {object} object{error=string} // @Failure 403 {object} object{error=string} @@ -619,17 +487,16 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { return } - // Fetch questions to embed in response - questions, err := app.store.Settings.GetShortAnswerQuestions(r.Context()) + // Fetch schema to embed in response + schema, err := app.store.Settings.GetApplicationSchema(r.Context()) if err != nil { app.internalServerError(w, r, err) return } - // Return application with embedded questions - response := ApplicationWithQuestions{ - Application: application, - ShortAnswerQuestions: questions, + response := ApplicationWithSchema{ + Application: application, + ApplicationSchema: schema, } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -689,5 +556,4 @@ func (app *application) getApplicantEmailsByStatusHandler(w http.ResponseWriter, if err = app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) } - } From 5010461d849857d95f57b06be0b135d6e632d49d Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:05:39 -0500 Subject: [PATCH 05/17] refactor: replace SAQ settings handlers with application-schema Replace getShortAnswerQuestions/updateShortAnswerQuestions with getApplicationSchema/updateApplicationSchema. Update routes from /saquestions to /application-schema. --- cmd/api/api.go | 4 +-- cmd/api/settings.go | 64 +++++++++++++++++++++++---------------------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 143d427f..76296357 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -247,8 +247,8 @@ func (app *application) mount() http.Handler { // Configs r.Route("/settings", func(r chi.Router) { - r.Get("/saquestions", app.getShortAnswerQuestions) - r.Put("/saquestions", app.updateShortAnswerQuestions) + r.Get("/application-schema", app.getApplicationSchema) + r.Put("/application-schema", app.updateApplicationSchema) r.Get("/reviews-per-app", app.getReviewsPerApp) r.Post("/reviews-per-app", app.setReviewsPerApp) r.Put("/review-assignment-toggle", app.setReviewAssignmentToggle) diff --git a/cmd/api/settings.go b/cmd/api/settings.go index b4f9648a..8ea172b5 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -8,35 +8,35 @@ import ( "github.com/hackutd/portal/internal/store" ) -type UpdateShortAnswerQuestionsPayload struct { - Questions []store.ShortAnswerQuestion `json:"questions" validate:"required,dive"` +type UpdateApplicationSchemaPayload struct { + Fields []store.ApplicationSchemaField `json:"fields" validate:"required,dive"` } -type ShortAnswerQuestionsResponse struct { - Questions []store.ShortAnswerQuestion `json:"questions"` +type ApplicationSchemaResponse struct { + Fields []store.ApplicationSchemaField `json:"fields"` } -// getShortAnswerQuestions returns all configurable short answer questions +// getApplicationSchema returns the configurable application schema // -// @Summary Get short answer questions (Super Admin) -// @Description Returns all configurable short answer questions for hacker applications +// @Summary Get application schema (Super Admin) +// @Description Returns the configurable application schema fields for hacker applications // @Tags superadmin/settings // @Produce json -// @Success 200 {object} ShortAnswerQuestionsResponse +// @Success 200 {object} ApplicationSchemaResponse // @Failure 401 {object} object{error=string} // @Failure 403 {object} object{error=string} // @Failure 500 {object} object{error=string} // @Security CookieAuth -// @Router /superadmin/settings/saquestions [get] -func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.Request) { - questions, err := app.store.Settings.GetShortAnswerQuestions(r.Context()) +// @Router /superadmin/settings/application-schema [get] +func (app *application) getApplicationSchema(w http.ResponseWriter, r *http.Request) { + fields, err := app.store.Settings.GetApplicationSchema(r.Context()) if err != nil { app.internalServerError(w, r, err) return } - response := ShortAnswerQuestionsResponse{ - Questions: questions, + response := ApplicationSchemaResponse{ + Fields: fields, } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -44,23 +44,23 @@ func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.R } } -// updateShortAnswerQuestions replaces all short answer questions +// updateApplicationSchema replaces the application schema // -// @Summary Update short answer questions (Super Admin) -// @Description Replaces all short answer questions with the provided array +// @Summary Update application schema (Super Admin) +// @Description Replaces the application schema with the provided array of fields // @Tags superadmin/settings // @Accept json // @Produce json -// @Param questions body UpdateShortAnswerQuestionsPayload true "Questions to set" -// @Success 200 {object} ShortAnswerQuestionsResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} +// @Param fields body UpdateApplicationSchemaPayload true "Schema fields to set" +// @Success 200 {object} ApplicationSchemaResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} // @Security CookieAuth -// @Router /superadmin/settings/saquestions [put] -func (app *application) updateShortAnswerQuestions(w http.ResponseWriter, r *http.Request) { - var req UpdateShortAnswerQuestionsPayload +// @Router /superadmin/settings/application-schema [put] +func (app *application) updateApplicationSchema(w http.ResponseWriter, r *http.Request) { + var req UpdateApplicationSchemaPayload if err := readJSON(w, r, &req); err != nil { app.badRequestResponse(w, r, err) return @@ -73,20 +73,22 @@ func (app *application) updateShortAnswerQuestions(w http.ResponseWriter, r *htt // Validate unique IDs idMap := make(map[string]bool) - for _, q := range req.Questions { - if idMap[q.ID] { - app.badRequestResponse(w, r, errors.New("duplicate question ID: "+q.ID)) + for _, f := range req.Fields { + if idMap[f.ID] { + app.badRequestResponse(w, r, errors.New("duplicate field ID: "+f.ID)) return } - idMap[q.ID] = true + idMap[f.ID] = true } - if err := app.store.Settings.UpdateShortAnswerQuestions(r.Context(), req.Questions); err != nil { + if err := app.store.Settings.UpdateApplicationSchema(r.Context(), req.Fields); err != nil { app.internalServerError(w, r, err) return } - response := ShortAnswerQuestionsResponse(req) + response := ApplicationSchemaResponse{ + Fields: req.Fields, + } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) From af465ce474d3cca5834ae2e76e1b18e0b02f0da6 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:06:47 -0500 Subject: [PATCH 06/17] refactor: remove old short answer questions code Remove ShortAnswerQuestion struct, SettingsKeyShortAnswerQuestions constant, and Get/Update methods from settings store, interface, and mock. Application schema replaces this functionality. --- internal/store/mock_store.go | 13 -------- internal/store/settings.go | 59 +----------------------------------- internal/store/storage.go | 2 -- 3 files changed, 1 insertion(+), 73 deletions(-) diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index 4ba574e2..0469a60e 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -150,19 +150,6 @@ type MockSettingsStore struct { mock.Mock } -func (m *MockSettingsStore) GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]ShortAnswerQuestion), args.Error(1) -} - -func (m *MockSettingsStore) UpdateShortAnswerQuestions(ctx context.Context, questions []ShortAnswerQuestion) error { - args := m.Called(questions) - return args.Error(0) -} - func (m *MockSettingsStore) GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) { args := m.Called() if args.Get(0) == nil { diff --git a/internal/store/settings.go b/internal/store/settings.go index b8f6a382..a3f8c91f 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -7,20 +7,11 @@ import ( "errors" ) -// ShortAnswerQuestion represents a single configurable question -type ShortAnswerQuestion struct { - ID string `json:"id" validate:"required,min=1,max=50"` - Question string `json:"question" validate:"required,min=1,max=500"` - Required bool `json:"required"` - DisplayOrder int `json:"display_order" validate:"min=0"` -} - -// SettingsStore handles database operations for hackathon settings (e.g., short answer questions) +// SettingsStore handles database operations for hackathon settings type SettingsStore struct { db *sql.DB } -const SettingsKeyShortAnswerQuestions = "short_answer_questions" const SettingsKeyApplicationSchema = "application_schema" const SettingsKeyReviewsPerApplication = "reviews_per_application" const SettingsKeyReviewAssignmentToggle = "review_assignment_toggle" @@ -55,34 +46,6 @@ type ReviewAssignmentEntry struct { Enabled bool `json:"enabled"` } -// GetShortAnswerQuestions returns the parsed questions array -func (s *SettingsStore) GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error) { - ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) - defer cancel() - - query := ` - SELECT value - FROM settings - WHERE key = $1 - ` - - var value []byte - err := s.db.QueryRowContext(ctx, query, SettingsKeyShortAnswerQuestions).Scan(&value) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return []ShortAnswerQuestion{}, nil - } - return nil, err - } - - var questions []ShortAnswerQuestion - if err := json.Unmarshal(value, &questions); err != nil { - return nil, err - } - - return questions, nil -} - // GetApplicationSchema returns the parsed application form schema fields func (s *SettingsStore) GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) @@ -292,26 +255,6 @@ func resetReviewAssignmentToggle(ctx context.Context, tx *sql.Tx) error { return err } -// UpdateShortAnswerQuestions replaces all questions with the provided array -func (s *SettingsStore) UpdateShortAnswerQuestions(ctx context.Context, questions []ShortAnswerQuestion) error { - ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) - defer cancel() - - value, err := json.Marshal(questions) - if err != nil { - return err - } - - query := ` - INSERT INTO settings (key, value) - VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value - ` - - _, err = s.db.ExecContext(ctx, query, SettingsKeyShortAnswerQuestions, string(value)) - return err -} - // parseReviewAssignmentEntries tries the new object format first, then falls back to legacy []string. func parseReviewAssignmentEntries(value []byte) ([]ReviewAssignmentEntry, error) { var entries []ReviewAssignmentEntry diff --git a/internal/store/storage.go b/internal/store/storage.go index be39a3d1..7b396d48 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -39,8 +39,6 @@ type Storage struct { GetEmailsByStatus(ctx context.Context, status ApplicationStatus) ([]UserEmailInfo, error) } Settings interface { - GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error) - UpdateShortAnswerQuestions(ctx context.Context, questions []ShortAnswerQuestion) error GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) UpdateApplicationSchema(ctx context.Context, fields []ApplicationSchemaField) error GetReviewsPerApplication(ctx context.Context) (int, error) From b17c252269b56240ae0ca5dbc6b14e30dfab1291 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:09:12 -0500 Subject: [PATCH 07/17] test: rewrite tests for JSONB applications and application-schema Update newCompleteApplication to use Responses JSONB field. Replace ShortAnswerQuestion mocks with ApplicationSchemaField mocks. Replace SAQ settings tests with application-schema tests. --- cmd/api/applications_test.go | 129 +++++++++++------------------------ cmd/api/settings_test.go | 42 ++++++------ 2 files changed, 62 insertions(+), 109 deletions(-) diff --git a/cmd/api/applications_test.go b/cmd/api/applications_test.go index e4e67d2b..f94070d6 100644 --- a/cmd/api/applications_test.go +++ b/cmd/api/applications_test.go @@ -17,47 +17,22 @@ import ( // newCompleteApplication returns a fully filled application ready for submission func newCompleteApplication(userID string) *store.Application { - firstName := "John" - lastName := "Doe" - phone := "+11234567890" - age := int16(20) - country := "US" - gender := "Male" - race := "Asian" - ethnicity := "Not Hispanic" - university := "UT Dallas" - major := "CS" - level := "Undergraduate" - hackathons := int16(2) - experience := "Intermediate" - heard := "Friend" - shirt := "M" - return &store.Application{ - ID: "app-1", - UserID: userID, - Status: store.StatusDraft, - FirstName: &firstName, - LastName: &lastName, - PhoneE164: &phone, - Age: &age, - CountryOfResidence: &country, - Gender: &gender, - Race: &race, - Ethnicity: ðnicity, - University: &university, - Major: &major, - LevelOfStudy: &level, - HackathonsAttendedCount: &hackathons, - SoftwareExperienceLevel: &experience, - HeardAbout: &heard, - ShirtSize: &shirt, - ShortAnswerResponses: json.RawMessage(`{"q1":"answer1"}`), - AckApplication: true, - AckMLHCOC: true, - AckMLHPrivacy: true, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "app-1", + UserID: userID, + Status: store.StatusDraft, + Responses: json.RawMessage(`{ + "first_name":"John","last_name":"Doe","phone_e164":"+11234567890", + "age":20,"country_of_residence":"US","gender":"Male","race":"Asian", + "ethnicity":"Not Hispanic","university":"UT Dallas","major":"CS", + "level_of_study":"Undergraduate","hackathons_attended_count":2, + "software_experience_level":"Intermediate","heard_about":"Friend", + "shirt_size":"M" + }`), + AckMLHCOC: true, + AckMLHPrivacy: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } } @@ -69,10 +44,10 @@ func TestGetOrCreateApplication(t *testing.T) { t.Run("should return existing application", func(t *testing.T) { user := newTestUser() existing := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft} - questions := []store.ShortAnswerQuestion{{ID: "q1", Question: "Why?"}} + schema := []store.ApplicationSchemaField{{ID: "first_name", Type: "text", Label: "First Name"}} mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) @@ -87,11 +62,11 @@ func TestGetOrCreateApplication(t *testing.T) { t.Run("should create draft when no application exists", func(t *testing.T) { user := newTestUser() - questions := []store.ShortAnswerQuestion{} + schema := []store.ApplicationSchemaField{} mockApps.On("GetByUserID", user.ID).Return(nil, store.ErrNotFound).Once() mockApps.On("Create", mock.AnythingOfType("*store.Application")).Return(nil).Once() - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) @@ -107,12 +82,12 @@ func TestGetOrCreateApplication(t *testing.T) { t.Run("should handle race condition on create conflict", func(t *testing.T) { user := newTestUser() existing := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft} - questions := []store.ShortAnswerQuestion{} + schema := []store.ApplicationSchemaField{} mockApps.On("GetByUserID", user.ID).Return(nil, store.ErrNotFound).Once() mockApps.On("Create", mock.AnythingOfType("*store.Application")).Return(store.ErrConflict).Once() mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) @@ -130,14 +105,14 @@ func TestUpdateApplication(t *testing.T) { app := newTestApplication(t) mockApps := app.store.Application.(*store.MockApplicationStore) - t.Run("should update draft application fields", func(t *testing.T) { + t.Run("should update draft application responses", func(t *testing.T) { user := newTestUser() existing := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft} mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() mockApps.On("Update", mock.AnythingOfType("*store.Application")).Return(nil).Once() - body := `{"first_name": "Jane", "last_name": "Doe"}` + body := `{"responses": {"first_name": "Jane", "last_name": "Doe"}}` req, err := http.NewRequest(http.MethodPatch, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -155,7 +130,7 @@ func TestUpdateApplication(t *testing.T) { mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() - body := `{"first_name": "Jane"}` + body := `{"responses": {"first_name": "Jane"}}` req, err := http.NewRequest(http.MethodPatch, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -172,7 +147,7 @@ func TestUpdateApplication(t *testing.T) { mockApps.On("GetByUserID", user.ID).Return(nil, store.ErrNotFound).Once() - body := `{"first_name": "Jane"}` + body := `{"responses": {"first_name": "Jane"}}` req, err := http.NewRequest(http.MethodPatch, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -183,25 +158,6 @@ func TestUpdateApplication(t *testing.T) { mockApps.AssertExpectations(t) }) - - t.Run("should return 400 on validation failure", func(t *testing.T) { - user := newTestUser() - existing := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft} - - mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() - - // age out of range - body := `{"age": -5}` - req, err := http.NewRequest(http.MethodPatch, "/", strings.NewReader(body)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - req = setUserContext(req, user) - - rr := executeRequest(req, http.HandlerFunc(app.updateApplicationHandler)) - checkResponseCode(t, http.StatusBadRequest, rr.Code) - - mockApps.AssertExpectations(t) - }) } func TestSubmitApplication(t *testing.T) { @@ -212,12 +168,13 @@ func TestSubmitApplication(t *testing.T) { t.Run("should submit a complete application", func(t *testing.T) { user := newTestUser() application := newCompleteApplication(user.ID) - questions := []store.ShortAnswerQuestion{ - {ID: "q1", Question: "Why?", Required: true}, + schema := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true}, + {ID: "last_name", Type: "text", Label: "Last Name", Required: true}, } mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() mockApps.On("Submit", application).Return(nil).Once() req, err := http.NewRequest(http.MethodPost, "/", nil) @@ -233,12 +190,14 @@ func TestSubmitApplication(t *testing.T) { t.Run("should return 400 when required fields are missing", func(t *testing.T) { user := newTestUser() - // empty draft application — all fields nil + // empty draft application — no responses application := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft} - questions := []store.ShortAnswerQuestion{} + schema := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true}, + } mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() req, err := http.NewRequest(http.MethodPost, "/", nil) require.NoError(t, err) @@ -258,17 +217,18 @@ func TestSubmitApplication(t *testing.T) { mockSettings.AssertExpectations(t) }) - t.Run("should return 400 when required short answer is blank", func(t *testing.T) { + t.Run("should return 400 when required field is blank", func(t *testing.T) { user := newTestUser() application := newCompleteApplication(user.ID) - application.ShortAnswerResponses = json.RawMessage(`{"q1":""}`) // blank answer + application.Responses = json.RawMessage(`{"first_name":"","last_name":"Doe"}`) - questions := []store.ShortAnswerQuestion{ - {ID: "q1", Question: "Why?", Required: true}, + schema := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true}, + {ID: "last_name", Type: "text", Label: "Last Name", Required: true}, } mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() req, err := http.NewRequest(http.MethodPost, "/", nil) require.NoError(t, err) @@ -282,7 +242,7 @@ func TestSubmitApplication(t *testing.T) { } err = json.NewDecoder(rr.Body).Decode(&body) require.NoError(t, err) - assert.Contains(t, body.Error, "short_answer:q1") + assert.Contains(t, body.Error, "first_name") mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) @@ -579,10 +539,3 @@ func TestSetApplicationStatus(t *testing.T) { mockApps.AssertExpectations(t) }) } - -// func TestGetApplicantEmailsByStatus(t *testing.T) { -// app := newTestApplication(t) -// mockApps := app.store.Application.(*store.MockApplicationStore) //TODO: write test function. NOT FINISHED -// -// mockApps.On("GetEmailsByStatus", user.ID).Return(application, nil).Once() -// } diff --git a/cmd/api/settings_test.go b/cmd/api/settings_test.go index 8ef55c33..a6c44c9e 100644 --- a/cmd/api/settings_test.go +++ b/cmd/api/settings_test.go @@ -11,79 +11,79 @@ import ( "github.com/stretchr/testify/require" ) -func TestGetShortAnswerQuestions(t *testing.T) { +func TestGetApplicationSchema(t *testing.T) { app := newTestApplication(t) mockSettings := app.store.Settings.(*store.MockSettingsStore) - t.Run("should return questions", func(t *testing.T) { - questions := []store.ShortAnswerQuestion{ - {ID: "q1", Question: "Why do you want to attend?", Required: true, DisplayOrder: 0}, - {ID: "q2", Question: "Tell us about a project", Required: false, DisplayOrder: 1}, + t.Run("should return schema fields", func(t *testing.T) { + fields := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true, DisplayOrder: 0}, + {ID: "university", Type: "text", Label: "University", Required: false, DisplayOrder: 1}, } - mockSettings.On("GetShortAnswerQuestions").Return(questions, nil).Once() + mockSettings.On("GetApplicationSchema").Return(fields, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) req = setUserContext(req, newSuperAdminUser()) - rr := executeRequest(req, http.HandlerFunc(app.getShortAnswerQuestions)) + rr := executeRequest(req, http.HandlerFunc(app.getApplicationSchema)) checkResponseCode(t, http.StatusOK, rr.Code) var body struct { - Data ShortAnswerQuestionsResponse `json:"data"` + Data ApplicationSchemaResponse `json:"data"` } err = json.NewDecoder(rr.Body).Decode(&body) require.NoError(t, err) - assert.Len(t, body.Data.Questions, 2) + assert.Len(t, body.Data.Fields, 2) mockSettings.AssertExpectations(t) }) } -func TestUpdateShortAnswerQuestions(t *testing.T) { +func TestUpdateApplicationSchema(t *testing.T) { app := newTestApplication(t) mockSettings := app.store.Settings.(*store.MockSettingsStore) - t.Run("should update questions", func(t *testing.T) { - questions := []store.ShortAnswerQuestion{ - {ID: "q1", Question: "Why?", Required: true, DisplayOrder: 0}, + t.Run("should update schema", func(t *testing.T) { + fields := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true, DisplayOrder: 0}, } - mockSettings.On("UpdateShortAnswerQuestions", questions).Return(nil).Once() + mockSettings.On("UpdateApplicationSchema", fields).Return(nil).Once() - body := `{"questions":[{"id":"q1","question":"Why?","required":true,"display_order":0}]}` + body := `{"fields":[{"id":"first_name","type":"text","label":"First Name","required":true,"display_order":0}]}` req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req = setUserContext(req, newSuperAdminUser()) - rr := executeRequest(req, http.HandlerFunc(app.updateShortAnswerQuestions)) + rr := executeRequest(req, http.HandlerFunc(app.updateApplicationSchema)) checkResponseCode(t, http.StatusOK, rr.Code) mockSettings.AssertExpectations(t) }) - t.Run("should return 400 for duplicate question IDs", func(t *testing.T) { - body := `{"questions":[{"id":"q1","question":"A?","required":true,"display_order":0},{"id":"q1","question":"B?","required":false,"display_order":1}]}` + t.Run("should return 400 for duplicate field IDs", func(t *testing.T) { + body := `{"fields":[{"id":"f1","type":"text","label":"A","required":true,"display_order":0},{"id":"f1","type":"text","label":"B","required":false,"display_order":1}]}` req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req = setUserContext(req, newSuperAdminUser()) - rr := executeRequest(req, http.HandlerFunc(app.updateShortAnswerQuestions)) + rr := executeRequest(req, http.HandlerFunc(app.updateApplicationSchema)) checkResponseCode(t, http.StatusBadRequest, rr.Code) }) - t.Run("should return 400 for empty questions array", func(t *testing.T) { + t.Run("should return 400 for empty fields array", func(t *testing.T) { body := `{}` req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req = setUserContext(req, newSuperAdminUser()) - rr := executeRequest(req, http.HandlerFunc(app.updateShortAnswerQuestions)) + rr := executeRequest(req, http.HandlerFunc(app.updateApplicationSchema)) checkResponseCode(t, http.StatusBadRequest, rr.Code) }) } From b9ba6c14843b5897a711afb2b81cef0cda3677c1 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 12 Apr 2026 20:10:47 -0500 Subject: [PATCH 08/17] feat: add schema-driven type validation on submit Add validateResponses function that validates each response value against its schema field: type checks for text/number/select/ multi_select/checkbox, min/max for numbers, maxLength for strings, and option validation for select/multi_select fields. --- cmd/api/applications.go | 117 ++++++++++++++++++++++++++++++----- cmd/api/applications_test.go | 67 +++++++++++++++++++- 2 files changed, 168 insertions(+), 16 deletions(-) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index 6931751c..d12b55b9 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -208,27 +208,19 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. responses = make(map[string]interface{}) } - // Validate required schema fields - var missing []string - for _, field := range schema { - if field.Required { - val, exists := responses[field.ID] - if !exists || isEmpty(val) { - missing = append(missing, field.ID) - } - } - } + // Validate responses against schema + validationErrors := validateResponses(schema, responses) // Validate acknowledgments if !application.AckMLHCOC { - missing = append(missing, "ack_mlh_coc") + validationErrors = append(validationErrors, "ack_mlh_coc is required") } if !application.AckMLHPrivacy { - missing = append(missing, "ack_mlh_privacy") + validationErrors = append(validationErrors, "ack_mlh_privacy is required") } - if len(missing) > 0 { - app.badRequestResponse(w, r, fmt.Errorf("missing required fields: %v", missing)) + if len(validationErrors) > 0 { + app.badRequestResponse(w, r, fmt.Errorf("validation errors: %v", validationErrors)) return } @@ -243,6 +235,93 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. } } +// validateResponses checks each response value against its schema field definition. +// Returns a list of human-readable validation error strings. +func validateResponses(schema []store.ApplicationSchemaField, responses map[string]interface{}) []string { + var errs []string + + for _, field := range schema { + val, exists := responses[field.ID] + + // Required check + if field.Required && (!exists || isEmpty(val)) { + errs = append(errs, field.ID+" is required") + continue + } + + // Skip further validation if value is absent or empty + if !exists || isEmpty(val) { + continue + } + + // Type-specific validation + switch field.Type { + case "text", "textarea", "phone": + s, ok := val.(string) + if !ok { + errs = append(errs, field.ID+" must be a string") + continue + } + if maxLen, ok := field.Validation["maxLength"]; ok { + if ml, ok := maxLen.(float64); ok && float64(len(s)) > ml { + errs = append(errs, fmt.Sprintf("%s exceeds max length of %d", field.ID, int(ml))) + } + } + + case "number": + n, ok := val.(float64) + if !ok { + errs = append(errs, field.ID+" must be a number") + continue + } + if minVal, ok := field.Validation["min"]; ok { + if mv, ok := minVal.(float64); ok && n < mv { + errs = append(errs, fmt.Sprintf("%s must be at least %v", field.ID, mv)) + } + } + if maxVal, ok := field.Validation["max"]; ok { + if mv, ok := maxVal.(float64); ok && n > mv { + errs = append(errs, fmt.Sprintf("%s must be at most %v", field.ID, mv)) + } + } + + case "select": + s, ok := val.(string) + if !ok { + errs = append(errs, field.ID+" must be a string") + continue + } + if len(field.Options) > 0 && !containsString(field.Options, s) { + errs = append(errs, field.ID+" has invalid option: "+s) + } + + case "multi_select": + arr, ok := val.([]interface{}) + if !ok { + errs = append(errs, field.ID+" must be an array") + continue + } + for _, item := range arr { + s, ok := item.(string) + if !ok { + errs = append(errs, field.ID+" array items must be strings") + break + } + if len(field.Options) > 0 && !containsString(field.Options, s) { + errs = append(errs, field.ID+" has invalid option: "+s) + } + } + + case "checkbox": + if _, ok := val.(bool); !ok { + errs = append(errs, field.ID+" must be a boolean") + } + } + } + + return errs +} + // isEmpty checks if a response value is considered empty func isEmpty(val interface{}) bool { if val == nil { @@ -258,6 +337,16 @@ func isEmpty(val interface{}) bool { } } +// containsString checks if a string slice contains the given value +func containsString(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} + // getApplicationStatsHandler returns aggregated statistics for all applications // // @Summary Get application stats (Admin) diff --git a/cmd/api/applications_test.go b/cmd/api/applications_test.go index f94070d6..c9e94e57 100644 --- a/cmd/api/applications_test.go +++ b/cmd/api/applications_test.go @@ -211,7 +211,7 @@ func TestSubmitApplication(t *testing.T) { } err = json.NewDecoder(rr.Body).Decode(&body) require.NoError(t, err) - assert.Contains(t, body.Error, "missing required fields") + assert.Contains(t, body.Error, "validation errors") mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) @@ -242,7 +242,70 @@ func TestSubmitApplication(t *testing.T) { } err = json.NewDecoder(rr.Body).Decode(&body) require.NoError(t, err) - assert.Contains(t, body.Error, "first_name") + assert.Contains(t, body.Error, "first_name is required") + + mockApps.AssertExpectations(t) + mockSettings.AssertExpectations(t) + }) + + t.Run("should return 400 when select field has invalid option", func(t *testing.T) { + user := newTestUser() + application := newCompleteApplication(user.ID) + application.Responses = json.RawMessage(`{"first_name":"John","gender":"InvalidOption"}`) + + schema := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true}, + {ID: "gender", Type: "select", Label: "Gender", Required: false, Options: []string{"Male", "Female", "Other"}}, + } + + mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + + req, err := http.NewRequest(http.MethodPost, "/", nil) + require.NoError(t, err) + req = setUserContext(req, user) + + rr := executeRequest(req, http.HandlerFunc(app.submitApplicationHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + var body struct { + Error string `json:"error"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Contains(t, body.Error, "gender has invalid option") + + mockApps.AssertExpectations(t) + mockSettings.AssertExpectations(t) + }) + + t.Run("should return 400 when number field exceeds max", func(t *testing.T) { + user := newTestUser() + application := newCompleteApplication(user.ID) + application.Responses = json.RawMessage(`{"first_name":"John","last_name":"Doe","age":200}`) + + schema := []store.ApplicationSchemaField{ + {ID: "first_name", Type: "text", Label: "First Name", Required: true}, + {ID: "last_name", Type: "text", Label: "Last Name", Required: true}, + {ID: "age", Type: "number", Label: "Age", Required: false, Validation: map[string]interface{}{"min": float64(1), "max": float64(150)}}, + } + + mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + + req, err := http.NewRequest(http.MethodPost, "/", nil) + require.NoError(t, err) + req = setUserContext(req, user) + + rr := executeRequest(req, http.HandlerFunc(app.submitApplicationHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + var body struct { + Error string `json:"error"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Contains(t, body.Error, "age must be at most") mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) From b7fc3f95a32c40fa288888bcb4e4ef2de33bdbba Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Mon, 13 Apr 2026 10:45:48 -0500 Subject: [PATCH 09/17] feat: new migrations --- .../000001_extensions_and_functions.down.sql | 5 + .../000001_extensions_and_functions.up.sql | 11 + .../000002_create_users.down.sql | 5 + .../new-migrations/000002_create_users.up.sql | 28 + .../000003_create_settings.down.sql | 2 + .../000003_create_settings.up.sql | 11 + .../000004_seed_settings.down.sql | 10 + .../000004_seed_settings.up.sql | 63 +++ .../000005_create_applications.down.sql | 10 + .../000005_create_applications.up.sql | 54 ++ ...000006_create_application_reviews.down.sql | 9 + .../000006_create_application_reviews.up.sql | 33 ++ .../000007_create_vote_count_trigger.down.sql | 2 + .../000007_create_vote_count_trigger.up.sql | 73 +++ .../000008_create_scans.down.sql | 2 + .../new-migrations/000008_create_scans.up.sql | 12 + .../000009_create_schedule.down.sql | 3 + .../000009_create_schedule.up.sql | 17 + .../000010_create_sponsors.down.sql | 2 + .../000010_create_sponsors.up.sql | 16 + docs/docs.go | 513 ++++++------------ 21 files changed, 547 insertions(+), 334 deletions(-) create mode 100644 cmd/migrate/new-migrations/000001_extensions_and_functions.down.sql create mode 100644 cmd/migrate/new-migrations/000001_extensions_and_functions.up.sql create mode 100644 cmd/migrate/new-migrations/000002_create_users.down.sql create mode 100644 cmd/migrate/new-migrations/000002_create_users.up.sql create mode 100644 cmd/migrate/new-migrations/000003_create_settings.down.sql create mode 100644 cmd/migrate/new-migrations/000003_create_settings.up.sql create mode 100644 cmd/migrate/new-migrations/000004_seed_settings.down.sql create mode 100644 cmd/migrate/new-migrations/000004_seed_settings.up.sql create mode 100644 cmd/migrate/new-migrations/000005_create_applications.down.sql create mode 100644 cmd/migrate/new-migrations/000005_create_applications.up.sql create mode 100644 cmd/migrate/new-migrations/000006_create_application_reviews.down.sql create mode 100644 cmd/migrate/new-migrations/000006_create_application_reviews.up.sql create mode 100644 cmd/migrate/new-migrations/000007_create_vote_count_trigger.down.sql create mode 100644 cmd/migrate/new-migrations/000007_create_vote_count_trigger.up.sql create mode 100644 cmd/migrate/new-migrations/000008_create_scans.down.sql create mode 100644 cmd/migrate/new-migrations/000008_create_scans.up.sql create mode 100644 cmd/migrate/new-migrations/000009_create_schedule.down.sql create mode 100644 cmd/migrate/new-migrations/000009_create_schedule.up.sql create mode 100644 cmd/migrate/new-migrations/000010_create_sponsors.down.sql create mode 100644 cmd/migrate/new-migrations/000010_create_sponsors.up.sql diff --git a/cmd/migrate/new-migrations/000001_extensions_and_functions.down.sql b/cmd/migrate/new-migrations/000001_extensions_and_functions.down.sql new file mode 100644 index 00000000..110d4f18 --- /dev/null +++ b/cmd/migrate/new-migrations/000001_extensions_and_functions.down.sql @@ -0,0 +1,5 @@ +DROP FUNCTION IF EXISTS set_updated_at(); + +DROP EXTENSION IF EXISTS pg_trgm; +DROP EXTENSION IF EXISTS pgcrypto; +DROP EXTENSION IF EXISTS citext; diff --git a/cmd/migrate/new-migrations/000001_extensions_and_functions.up.sql b/cmd/migrate/new-migrations/000001_extensions_and_functions.up.sql new file mode 100644 index 00000000..aeda3392 --- /dev/null +++ b/cmd/migrate/new-migrations/000001_extensions_and_functions.up.sql @@ -0,0 +1,11 @@ +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/cmd/migrate/new-migrations/000002_create_users.down.sql b/cmd/migrate/new-migrations/000002_create_users.down.sql new file mode 100644 index 00000000..289181ee --- /dev/null +++ b/cmd/migrate/new-migrations/000002_create_users.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_users_email_trgm; +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS auth_method; +DROP TYPE IF EXISTS user_role; diff --git a/cmd/migrate/new-migrations/000002_create_users.up.sql b/cmd/migrate/new-migrations/000002_create_users.up.sql new file mode 100644 index 00000000..2fd13daf --- /dev/null +++ b/cmd/migrate/new-migrations/000002_create_users.up.sql @@ -0,0 +1,28 @@ +DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('hacker', 'admin', 'super_admin'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE auth_method AS ENUM ('passwordless', 'google'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + supertokens_user_id TEXT UNIQUE NOT NULL, + email CITEXT UNIQUE NOT NULL, + role user_role NOT NULL DEFAULT 'hacker', + auth_method auth_method NOT NULL DEFAULT 'passwordless', + profile_picture_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_users_updated_at +BEFORE UPDATE ON users +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX idx_users_email_trgm ON users USING gin (email gin_trgm_ops); diff --git a/cmd/migrate/new-migrations/000003_create_settings.down.sql b/cmd/migrate/new-migrations/000003_create_settings.down.sql new file mode 100644 index 00000000..bcac194a --- /dev/null +++ b/cmd/migrate/new-migrations/000003_create_settings.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trg_settings_updated_at ON settings; +DROP TABLE IF EXISTS settings; diff --git a/cmd/migrate/new-migrations/000003_create_settings.up.sql b/cmd/migrate/new-migrations/000003_create_settings.up.sql new file mode 100644 index 00000000..1ab32318 --- /dev/null +++ b/cmd/migrate/new-migrations/000003_create_settings.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(255) UNIQUE NOT NULL, + value JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_settings_updated_at +BEFORE UPDATE ON settings +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/cmd/migrate/new-migrations/000004_seed_settings.down.sql b/cmd/migrate/new-migrations/000004_seed_settings.down.sql new file mode 100644 index 00000000..8b25743a --- /dev/null +++ b/cmd/migrate/new-migrations/000004_seed_settings.down.sql @@ -0,0 +1,10 @@ +DELETE FROM settings WHERE key IN ( + 'application_schema', + 'reviews_per_application', + 'review_assignment_toggle', + 'scan_types', + 'scan_stats', + 'admin_schedule_edit_enabled', + 'hackathon_date_range', + 'applications_enabled' +); diff --git a/cmd/migrate/new-migrations/000004_seed_settings.up.sql b/cmd/migrate/new-migrations/000004_seed_settings.up.sql new file mode 100644 index 00000000..be116735 --- /dev/null +++ b/cmd/migrate/new-migrations/000004_seed_settings.up.sql @@ -0,0 +1,63 @@ +-- Application form schema: defines all fields the hacker application form renders. +-- Super admins can modify this at runtime to add/remove/reorder fields. +-- Supported field types: text, number, textarea, select, multi_select, checkbox, phone +INSERT INTO settings (key, value) VALUES ('application_schema', '[ + {"id": "first_name", "type": "text", "label": "First Name", "required": true, "section": "personal", "display_order": 1}, + {"id": "last_name", "type": "text", "label": "Last Name", "required": true, "section": "personal", "display_order": 2}, + {"id": "phone", "type": "phone", "label": "Phone Number", "required": false, "section": "personal", "display_order": 3}, + {"id": "age", "type": "number", "label": "Age", "required": true, "section": "personal", "display_order": 4, "validation": {"min": 0, "max": 120}}, + {"id": "country_of_residence", "type": "text", "label": "Country of Residence", "required": false, "section": "personal", "display_order": 5}, + {"id": "gender", "type": "text", "label": "Gender", "required": false, "section": "personal", "display_order": 6}, + {"id": "race", "type": "text", "label": "Race", "required": false, "section": "personal", "display_order": 7}, + {"id": "ethnicity", "type": "text", "label": "Ethnicity", "required": false, "section": "personal", "display_order": 8}, + + {"id": "university", "type": "text", "label": "University", "required": true, "section": "education", "display_order": 10}, + {"id": "major", "type": "text", "label": "Major", "required": true, "section": "education", "display_order": 11}, + {"id": "level_of_study", "type": "select", "label": "Level of Study", "required": true, "section": "education", "display_order": 12, "options": ["Freshman", "Sophomore", "Junior", "Senior", "Graduate", "PhD", "Other"]}, + + {"id": "github", "type": "text", "label": "GitHub", "required": false, "section": "links", "display_order": 20}, + {"id": "linkedin", "type": "text", "label": "LinkedIn", "required": false, "section": "links", "display_order": 21}, + {"id": "website", "type": "text", "label": "Personal Website", "required": false, "section": "links", "display_order": 22}, + + {"id": "hackathons_attended", "type": "number", "label": "Hackathons Attended", "required": false, "section": "experience", "display_order": 30, "validation": {"min": 0}}, + {"id": "experience_level", "type": "select", "label": "Software Experience", "required": false, "section": "experience", "display_order": 31, "options": ["Beginner", "Intermediate", "Advanced", "Expert"]}, + {"id": "heard_about", "type": "text", "label": "How did you hear about us?", "required": false, "section": "experience", "display_order": 32}, + + {"id": "saq_1", "type": "textarea", "label": "Why do you want to attend this hackathon?", "required": true, "section": "short_answers", "display_order": 40, "validation": {"maxLength": 1000}}, + {"id": "saq_2", "type": "textarea", "label": "How many hackathons have you submitted to and what did you learn from them?", "required": true, "section": "short_answers", "display_order": 41, "validation": {"maxLength": 1000}}, + {"id": "saq_3", "type": "textarea", "label": "If you haven''t been to a hackathon, what do you hope to learn from this hackathon?", "required": true, "section": "short_answers", "display_order": 42, "validation": {"maxLength": 1000}}, + {"id": "saq_4", "type": "textarea", "label": "What are you looking forward to do at this hackathon?", "required": true, "section": "short_answers", "display_order": 43, "validation": {"maxLength": 1000}}, + + {"id": "shirt_size", "type": "select", "label": "Shirt Size", "required": false, "section": "logistics", "display_order": 50, "options": ["XS", "S", "M", "L", "XL", "XXL"]}, + {"id": "dietary_restrictions", "type": "multi_select", "label": "Dietary Restrictions", "required": false, "section": "logistics", "display_order": 51, "options": ["Vegan", "Vegetarian", "Halal", "Nuts", "Fish", "Wheat", "Dairy", "Eggs", "No Beef", "No Pork"]}, + {"id": "accommodations", "type": "textarea", "label": "Accommodations", "required": false, "section": "logistics", "display_order": 52} +]'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Reviews per application threshold +INSERT INTO settings (key, value) VALUES ('reviews_per_application', '3'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Review assignment toggle (seeded empty — populated when admins are added) +INSERT INTO settings (key, value) VALUES ('review_assignment_toggle', '[]'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Scan types +INSERT INTO settings (key, value) VALUES ('scan_types', '[{"name": "check_in", "display_name": "Check In", "category": "check_in", "is_active": true}]'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Scan stats cache +INSERT INTO settings (key, value) VALUES ('scan_stats', '{}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Admin schedule editing permission +INSERT INTO settings (key, value) VALUES ('admin_schedule_edit_enabled', 'true'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Hackathon date range +INSERT INTO settings (key, value) VALUES ('hackathon_date_range', '{"start_date": null, "end_date": null}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Applications enabled toggle +INSERT INTO settings (key, value) VALUES ('applications_enabled', 'true'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/cmd/migrate/new-migrations/000005_create_applications.down.sql b/cmd/migrate/new-migrations/000005_create_applications.down.sql new file mode 100644 index 00000000..7e403df3 --- /dev/null +++ b/cmd/migrate/new-migrations/000005_create_applications.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS idx_applications_responses; +DROP INDEX IF EXISTS idx_applications_reviews_completed; +DROP INDEX IF EXISTS idx_applications_created_at_id; +DROP INDEX IF EXISTS idx_applications_submitted_at; +DROP INDEX IF EXISTS idx_applications_status; + +DROP TRIGGER IF EXISTS trg_applications_updated_at ON applications; +DROP TABLE IF EXISTS applications; + +DROP TYPE IF EXISTS application_status; diff --git a/cmd/migrate/new-migrations/000005_create_applications.up.sql b/cmd/migrate/new-migrations/000005_create_applications.up.sql new file mode 100644 index 00000000..c1a03972 --- /dev/null +++ b/cmd/migrate/new-migrations/000005_create_applications.up.sql @@ -0,0 +1,54 @@ +DO $$ BEGIN + CREATE TYPE application_status AS ENUM ('draft', 'submitted', 'accepted', 'rejected', 'waitlisted'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status application_status NOT NULL DEFAULT 'draft', + + -- All form answers keyed by field id from application_schema setting + responses JSONB NOT NULL DEFAULT '{}', + + -- Resume file path (stored separately since it's a file reference, not a form field) + resume_path TEXT, + + -- AI detection percentage (set by admin tooling, not by the applicant) + ai_percent SMALLINT, + + -- MLH acknowledgements (always required for submission, not configurable) + ack_mlh_coc BOOLEAN NOT NULL DEFAULT FALSE, + ack_mlh_privacy BOOLEAN NOT NULL DEFAULT FALSE, + opt_in_mlh_emails BOOLEAN NOT NULL DEFAULT FALSE, + + -- Review vote counts (denormalized, maintained by trigger on application_reviews) + accept_votes INT NOT NULL DEFAULT 0, + reject_votes INT NOT NULL DEFAULT 0, + waitlist_votes INT NOT NULL DEFAULT 0, + reviews_assigned INT NOT NULL DEFAULT 0, + reviews_completed INT NOT NULL DEFAULT 0, + + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT applications_ai_percent_check CHECK (ai_percent IS NULL OR (ai_percent >= 0 AND ai_percent <= 100)), + CONSTRAINT applications_submitted_check CHECK ( + status <> 'submitted' + OR (submitted_at IS NOT NULL AND ack_mlh_coc AND ack_mlh_privacy) + ) +); + +CREATE TRIGGER trg_applications_updated_at +BEFORE UPDATE ON applications +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX idx_applications_status ON applications (status); +CREATE INDEX idx_applications_submitted_at ON applications (submitted_at DESC); +CREATE INDEX idx_applications_created_at_id ON applications (created_at DESC, id DESC); +CREATE INDEX idx_applications_reviews_completed ON applications (reviews_completed); + +-- GIN index for querying inside responses JSONB (e.g. filtering by university, name search) +CREATE INDEX idx_applications_responses ON applications USING gin (responses jsonb_path_ops); diff --git a/cmd/migrate/new-migrations/000006_create_application_reviews.down.sql b/cmd/migrate/new-migrations/000006_create_application_reviews.down.sql new file mode 100644 index 00000000..992d628e --- /dev/null +++ b/cmd/migrate/new-migrations/000006_create_application_reviews.down.sql @@ -0,0 +1,9 @@ +DROP INDEX IF EXISTS idx_reviews_app_completed; +DROP INDEX IF EXISTS idx_reviews_admin_pending; +DROP INDEX IF EXISTS idx_reviews_admin_id; +DROP INDEX IF EXISTS idx_reviews_application_id; + +DROP TRIGGER IF EXISTS trg_application_reviews_updated_at ON application_reviews; +DROP TABLE IF EXISTS application_reviews; + +DROP TYPE IF EXISTS review_vote; diff --git a/cmd/migrate/new-migrations/000006_create_application_reviews.up.sql b/cmd/migrate/new-migrations/000006_create_application_reviews.up.sql new file mode 100644 index 00000000..3956b465 --- /dev/null +++ b/cmd/migrate/new-migrations/000006_create_application_reviews.up.sql @@ -0,0 +1,33 @@ +DO $$ BEGIN + CREATE TYPE review_vote AS ENUM ('accept', 'reject', 'waitlist'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS application_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + admin_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vote review_vote, + notes TEXT, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE(application_id, admin_id), + + CONSTRAINT vote_requires_reviewed_at CHECK ( + (vote IS NULL AND reviewed_at IS NULL) OR + (vote IS NOT NULL AND reviewed_at IS NOT NULL) + ) +); + +CREATE TRIGGER trg_application_reviews_updated_at +BEFORE UPDATE ON application_reviews +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX idx_reviews_application_id ON application_reviews(application_id); +CREATE INDEX idx_reviews_admin_id ON application_reviews(admin_id); +CREATE INDEX idx_reviews_admin_pending ON application_reviews(admin_id) WHERE vote IS NULL; +CREATE INDEX idx_reviews_app_completed ON application_reviews(application_id) WHERE vote IS NOT NULL; diff --git a/cmd/migrate/new-migrations/000007_create_vote_count_trigger.down.sql b/cmd/migrate/new-migrations/000007_create_vote_count_trigger.down.sql new file mode 100644 index 00000000..845cc7ad --- /dev/null +++ b/cmd/migrate/new-migrations/000007_create_vote_count_trigger.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trg_update_vote_counts ON application_reviews; +DROP FUNCTION IF EXISTS update_application_vote_counts(); diff --git a/cmd/migrate/new-migrations/000007_create_vote_count_trigger.up.sql b/cmd/migrate/new-migrations/000007_create_vote_count_trigger.up.sql new file mode 100644 index 00000000..ef6efdf2 --- /dev/null +++ b/cmd/migrate/new-migrations/000007_create_vote_count_trigger.up.sql @@ -0,0 +1,73 @@ +CREATE OR REPLACE FUNCTION update_application_vote_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE applications + SET reviews_assigned = reviews_assigned + 1, + updated_at = now() + WHERE id = NEW.application_id; + + IF NEW.vote IS NOT NULL THEN + UPDATE applications + SET reviews_completed = reviews_completed + 1, + accept_votes = accept_votes + CASE WHEN NEW.vote = 'accept' THEN 1 ELSE 0 END, + reject_votes = reject_votes + CASE WHEN NEW.vote = 'reject' THEN 1 ELSE 0 END, + waitlist_votes = waitlist_votes + CASE WHEN NEW.vote = 'waitlist' THEN 1 ELSE 0 END, + updated_at = now() + WHERE id = NEW.application_id; + END IF; + + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.vote IS NULL AND NEW.vote IS NOT NULL THEN + UPDATE applications + SET reviews_completed = reviews_completed + 1, + accept_votes = accept_votes + CASE WHEN NEW.vote = 'accept' THEN 1 ELSE 0 END, + reject_votes = reject_votes + CASE WHEN NEW.vote = 'reject' THEN 1 ELSE 0 END, + waitlist_votes = waitlist_votes + CASE WHEN NEW.vote = 'waitlist' THEN 1 ELSE 0 END, + updated_at = now() + WHERE id = NEW.application_id; + ELSIF OLD.vote IS NOT NULL AND NEW.vote IS NOT NULL AND OLD.vote <> NEW.vote THEN + UPDATE applications + SET accept_votes = accept_votes + - CASE WHEN OLD.vote = 'accept' THEN 1 ELSE 0 END + + CASE WHEN NEW.vote = 'accept' THEN 1 ELSE 0 END, + reject_votes = reject_votes + - CASE WHEN OLD.vote = 'reject' THEN 1 ELSE 0 END + + CASE WHEN NEW.vote = 'reject' THEN 1 ELSE 0 END, + waitlist_votes = waitlist_votes + - CASE WHEN OLD.vote = 'waitlist' THEN 1 ELSE 0 END + + CASE WHEN NEW.vote = 'waitlist' THEN 1 ELSE 0 END, + updated_at = now() + WHERE id = NEW.application_id; + ELSIF OLD.vote IS NOT NULL AND NEW.vote IS NULL THEN + UPDATE applications + SET reviews_completed = reviews_completed - 1, + accept_votes = accept_votes - CASE WHEN OLD.vote = 'accept' THEN 1 ELSE 0 END, + reject_votes = reject_votes - CASE WHEN OLD.vote = 'reject' THEN 1 ELSE 0 END, + waitlist_votes = waitlist_votes - CASE WHEN OLD.vote = 'waitlist' THEN 1 ELSE 0 END, + updated_at = now() + WHERE id = NEW.application_id; + END IF; + + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE applications + SET reviews_assigned = reviews_assigned - 1, + reviews_completed = reviews_completed - CASE WHEN OLD.vote IS NOT NULL THEN 1 ELSE 0 END, + accept_votes = accept_votes - CASE WHEN OLD.vote = 'accept' THEN 1 ELSE 0 END, + reject_votes = reject_votes - CASE WHEN OLD.vote = 'reject' THEN 1 ELSE 0 END, + waitlist_votes = waitlist_votes - CASE WHEN OLD.vote = 'waitlist' THEN 1 ELSE 0 END, + updated_at = now() + WHERE id = OLD.application_id; + + RETURN OLD; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_vote_counts +AFTER INSERT OR UPDATE OR DELETE ON application_reviews +FOR EACH ROW EXECUTE FUNCTION update_application_vote_counts(); diff --git a/cmd/migrate/new-migrations/000008_create_scans.down.sql b/cmd/migrate/new-migrations/000008_create_scans.down.sql new file mode 100644 index 00000000..5e69c8af --- /dev/null +++ b/cmd/migrate/new-migrations/000008_create_scans.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_scans_scan_type; +DROP TABLE IF EXISTS scans; diff --git a/cmd/migrate/new-migrations/000008_create_scans.up.sql b/cmd/migrate/new-migrations/000008_create_scans.up.sql new file mode 100644 index 00000000..88a48a89 --- /dev/null +++ b/cmd/migrate/new-migrations/000008_create_scans.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS scans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scan_type TEXT NOT NULL, + scanned_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE(user_id, scan_type) +); + +CREATE INDEX idx_scans_scan_type ON scans(scan_type); diff --git a/cmd/migrate/new-migrations/000009_create_schedule.down.sql b/cmd/migrate/new-migrations/000009_create_schedule.down.sql new file mode 100644 index 00000000..02dec4a5 --- /dev/null +++ b/cmd/migrate/new-migrations/000009_create_schedule.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_schedule_start_time; +DROP TRIGGER IF EXISTS trg_schedule_updated_at ON schedule; +DROP TABLE IF EXISTS schedule; diff --git a/cmd/migrate/new-migrations/000009_create_schedule.up.sql b/cmd/migrate/new-migrations/000009_create_schedule.up.sql new file mode 100644 index 00000000..b0134764 --- /dev/null +++ b/cmd/migrate/new-migrations/000009_create_schedule.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS schedule ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + location TEXT NOT NULL DEFAULT '', + tags TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_schedule_updated_at +BEFORE UPDATE ON schedule +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX idx_schedule_start_time ON schedule(start_time); diff --git a/cmd/migrate/new-migrations/000010_create_sponsors.down.sql b/cmd/migrate/new-migrations/000010_create_sponsors.down.sql new file mode 100644 index 00000000..bc2edf3f --- /dev/null +++ b/cmd/migrate/new-migrations/000010_create_sponsors.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trg_sponsors_updated_at ON sponsors; +DROP TABLE IF EXISTS sponsors; diff --git a/cmd/migrate/new-migrations/000010_create_sponsors.up.sql b/cmd/migrate/new-migrations/000010_create_sponsors.up.sql new file mode 100644 index 00000000..496794f9 --- /dev/null +++ b/cmd/migrate/new-migrations/000010_create_sponsors.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS sponsors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'standard', + logo_data TEXT NOT NULL DEFAULT '', + logo_content_type TEXT NOT NULL DEFAULT '', + website_url TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + display_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_sponsors_updated_at +BEFORE UPDATE ON sponsors +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/docs/docs.go b/docs/docs.go index 103d7506..8caa7c7e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -190,7 +190,7 @@ const docTemplate = `{ "CookieAuth": [] } ], - "description": "Returns a single application by its ID with embedded short answer questions", + "description": "Returns a single application by its ID with embedded application schema", "produces": [ "application/json" ], @@ -211,7 +211,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ApplicationWithQuestions" + "$ref": "#/definitions/main.ApplicationWithSchema" } }, "400": { @@ -1962,7 +1962,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/store.Application" + "$ref": "#/definitions/main.ApplicationWithSchema" } }, "401": { @@ -2227,7 +2227,7 @@ const docTemplate = `{ "CookieAuth": [] } ], - "description": "Submits the authenticated user's application for review. All required fields must be filled and acknowledgments must be accepted. Application must be in draft status.", + "description": "Submits the authenticated user's application for review. All required schema fields must be filled and acknowledgments must be accepted. Application must be in draft status.", "produces": [ "application/json" ], @@ -2958,51 +2958,26 @@ const docTemplate = `{ } } }, - "/superadmin/settings/applications-enabled": { - "put": { + "/superadmin/settings/application-schema": { + "get": { "security": [ { "CookieAuth": [] } ], - "description": "Sets whether the application portal is currently open for submissions. Requires SuperAdmin privileges.", - "consumes": [ - "application/json" - ], + "description": "Returns the configurable application schema fields for hacker applications", "produces": [ "application/json" ], "tags": [ "superadmin/settings" ], - "summary": "Set applications enabled status (Super Admin)", - "parameters": [ - { - "description": "Enable or disable applications", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/main.SetApplicationsEnabledPayload" - } - } - ], + "summary": "Get application schema (Super Admin)", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ApplicationsEnabledResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/definitions/main.ApplicationSchemaResponse" } }, "401": { @@ -3039,28 +3014,51 @@ const docTemplate = `{ } } } - } - }, - "/superadmin/settings/hackathon-date-range": { - "get": { + }, + "put": { "security": [ { "CookieAuth": [] } ], - "description": "Returns configured hackathon start and end dates", + "description": "Replaces the application schema with the provided array of fields", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "superadmin/settings" ], - "summary": "Get hackathon date range (Super Admin)", + "summary": "Update application schema (Super Admin)", + "parameters": [ + { + "description": "Schema fields to set", + "name": "fields", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.UpdateApplicationSchemaPayload" + } + } + ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.HackathonDateRangeResponse" + "$ref": "#/definitions/main.ApplicationSchemaResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } } }, "401": { @@ -3097,14 +3095,16 @@ const docTemplate = `{ } } } - }, - "post": { + } + }, + "/superadmin/settings/applications-enabled": { + "put": { "security": [ { "CookieAuth": [] } ], - "description": "Updates configured hackathon start and end dates. Range must be at most 7 days inclusive.", + "description": "Sets whether the application portal is currently open for submissions. Requires SuperAdmin privileges.", "consumes": [ "application/json" ], @@ -3114,15 +3114,15 @@ const docTemplate = `{ "tags": [ "superadmin/settings" ], - "summary": "Set hackathon date range (Super Admin)", + "summary": "Set applications enabled status (Super Admin)", "parameters": [ { - "description": "Hackathon date range", - "name": "range", + "description": "Enable or disable applications", + "name": "payload", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/main.SetHackathonDateRangePayload" + "$ref": "#/definitions/main.SetApplicationsEnabledPayload" } } ], @@ -3130,7 +3130,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.HackathonDateRangeResponse" + "$ref": "#/definitions/main.ApplicationsEnabledResponse" } }, "400": { @@ -3180,51 +3180,26 @@ const docTemplate = `{ } } }, - "/superadmin/settings/review-assignment-toggle": { - "put": { + "/superadmin/settings/hackathon-date-range": { + "get": { "security": [ { "CookieAuth": [] } ], - "description": "Updates whether automatic review assignment is enabled for a specific super admin", - "consumes": [ - "application/json" - ], + "description": "Returns configured hackathon start and end dates", "produces": [ "application/json" ], "tags": [ "superadmin/settings" ], - "summary": "Set review assignment enabled state for a user (Super Admin)", - "parameters": [ - { - "description": "Review assignment enabled state", - "name": "enabled", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/main.SetReviewAssignmentTogglePayload" - } - } - ], + "summary": "Get hackathon date range (Super Admin)", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ReviewAssignmentToggleResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/definitions/main.HackathonDateRangeResponse" } }, "401": { @@ -3261,28 +3236,51 @@ const docTemplate = `{ } } } - } - }, - "/superadmin/settings/reviews-per-app": { - "get": { + }, + "post": { "security": [ { "CookieAuth": [] } ], - "description": "Returns the number of reviews required per application", + "description": "Updates configured hackathon start and end dates. Range must be at most 7 days inclusive.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "superadmin/settings" ], - "summary": "Get reviews per application (Super Admin)", + "summary": "Set hackathon date range (Super Admin)", + "parameters": [ + { + "description": "Hackathon date range", + "name": "range", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.SetHackathonDateRangePayload" + } + } + ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ReviewsPerAppResponse" + "$ref": "#/definitions/main.HackathonDateRangeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } } }, "401": { @@ -3319,14 +3317,16 @@ const docTemplate = `{ } } } - }, - "post": { + } + }, + "/superadmin/settings/review-assignment-toggle": { + "put": { "security": [ { "CookieAuth": [] } ], - "description": "Sets the number of reviews required per application", + "description": "Updates whether automatic review assignment is enabled for a specific super admin", "consumes": [ "application/json" ], @@ -3336,15 +3336,15 @@ const docTemplate = `{ "tags": [ "superadmin/settings" ], - "summary": "Set reviews per application (Super Admin)", + "summary": "Set review assignment enabled state for a user (Super Admin)", "parameters": [ { - "description": "Reviews per application value", - "name": "reviews_per_application", + "description": "Review assignment enabled state", + "name": "enabled", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/main.SetReviewsPerAppPayload" + "$ref": "#/definitions/main.SetReviewAssignmentTogglePayload" } } ], @@ -3352,7 +3352,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ReviewsPerAppResponse" + "$ref": "#/definitions/main.ReviewAssignmentToggleResponse" } }, "400": { @@ -3402,26 +3402,26 @@ const docTemplate = `{ } } }, - "/superadmin/settings/saquestions": { + "/superadmin/settings/reviews-per-app": { "get": { "security": [ { "CookieAuth": [] } ], - "description": "Returns all configurable short answer questions for hacker applications", + "description": "Returns the number of reviews required per application", "produces": [ "application/json" ], "tags": [ "superadmin/settings" ], - "summary": "Get short answer questions (Super Admin)", + "summary": "Get reviews per application (Super Admin)", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ShortAnswerQuestionsResponse" + "$ref": "#/definitions/main.ReviewsPerAppResponse" } }, "401": { @@ -3459,13 +3459,13 @@ const docTemplate = `{ } } }, - "put": { + "post": { "security": [ { "CookieAuth": [] } ], - "description": "Replaces all short answer questions with the provided array", + "description": "Sets the number of reviews required per application", "consumes": [ "application/json" ], @@ -3475,15 +3475,15 @@ const docTemplate = `{ "tags": [ "superadmin/settings" ], - "summary": "Update short answer questions (Super Admin)", + "summary": "Set reviews per application (Super Admin)", "parameters": [ { - "description": "Questions to set", - "name": "questions", + "description": "Reviews per application value", + "name": "reviews_per_application", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/main.UpdateShortAnswerQuestionsPayload" + "$ref": "#/definitions/main.SetReviewsPerAppPayload" } } ], @@ -3491,7 +3491,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.ShortAnswerQuestionsResponse" + "$ref": "#/definitions/main.ReviewsPerAppResponse" } }, "400": { @@ -3855,99 +3855,56 @@ const docTemplate = `{ } } }, - "main.ApplicationWithQuestions": { + "main.ApplicationSchemaResponse": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/store.ApplicationSchemaField" + } + } + } + }, + "main.ApplicationWithSchema": { "type": "object", "properties": { "accept_votes": { "type": "integer" }, - "accommodations": { - "type": "string" - }, - "ack_application": { - "type": "boolean" - }, "ack_mlh_coc": { "type": "boolean" }, "ack_mlh_privacy": { "type": "boolean" }, - "age": { - "type": "integer", - "maximum": 150, - "minimum": 1 - }, "ai_percent": { "type": "integer" }, - "country_of_residence": { - "type": "string", - "minLength": 1 - }, - "created_at": { - "type": "string" - }, - "dietary_restrictions": { + "application_schema": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/store.ApplicationSchemaField" } }, - "ethnicity": { - "type": "string", - "minLength": 1 - }, - "first_name": { - "type": "string", - "minLength": 1 - }, - "gender": { - "type": "string", - "minLength": 1 - }, - "github": { + "created_at": { "type": "string" }, - "hackathons_attended_count": { - "type": "integer", - "minimum": 0 - }, - "heard_about": { - "type": "string", - "minLength": 1 - }, "id": { "type": "string" }, - "last_name": { - "type": "string", - "minLength": 1 - }, - "level_of_study": { - "type": "string", - "minLength": 1 - }, - "linkedin": { - "type": "string" - }, - "major": { - "type": "string", - "minLength": 1 - }, "opt_in_mlh_emails": { "type": "boolean" }, - "phone_e164": { - "type": "string" - }, - "race": { - "type": "string", - "minLength": 1 - }, "reject_votes": { "type": "integer" }, + "responses": { + "type": "array", + "items": { + "type": "integer" + } + }, "resume_path": { "type": "string" }, @@ -3957,36 +3914,12 @@ const docTemplate = `{ "reviews_completed": { "type": "integer" }, - "shirt_size": { - "type": "string", - "minLength": 1 - }, - "short_answer_questions": { - "type": "array", - "items": { - "$ref": "#/definitions/store.ShortAnswerQuestion" - } - }, - "short_answer_responses": { - "type": "array", - "items": { - "type": "integer" - } - }, - "software_experience_level": { - "type": "string", - "minLength": 1 - }, "status": { "$ref": "#/definitions/store.ApplicationStatus" }, "submitted_at": { "type": "string" }, - "university": { - "type": "string", - "minLength": 1 - }, "updated_at": { "type": "string" }, @@ -3995,9 +3928,6 @@ const docTemplate = `{ }, "waitlist_votes": { "type": "integer" - }, - "website": { - "type": "string" } } }, @@ -4367,17 +4297,6 @@ const docTemplate = `{ } } }, - "main.ShortAnswerQuestionsResponse": { - "type": "object", - "properties": { - "questions": { - "type": "array", - "items": { - "$ref": "#/definitions/store.ShortAnswerQuestion" - } - } - } - }, "main.SponsorListResponse": { "type": "object", "properties": { @@ -4445,6 +4364,20 @@ const docTemplate = `{ "main.UpdateApplicationPayload": { "type": "object" }, + "main.UpdateApplicationSchemaPayload": { + "type": "object", + "required": [ + "fields" + ], + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/store.ApplicationSchemaField" + } + } + } + }, "main.UpdateRolePayload": { "type": "object", "required": [ @@ -4512,20 +4445,6 @@ const docTemplate = `{ } } }, - "main.UpdateShortAnswerQuestionsPayload": { - "type": "object", - "required": [ - "questions" - ], - "properties": { - "questions": { - "type": "array", - "items": { - "$ref": "#/definitions/store.ShortAnswerQuestion" - } - } - } - }, "main.UserResponse": { "type": "object", "properties": { @@ -4569,93 +4488,33 @@ const docTemplate = `{ "accept_votes": { "type": "integer" }, - "accommodations": { - "type": "string" - }, - "ack_application": { - "type": "boolean" - }, "ack_mlh_coc": { "type": "boolean" }, "ack_mlh_privacy": { "type": "boolean" }, - "age": { - "type": "integer", - "maximum": 150, - "minimum": 1 - }, "ai_percent": { "type": "integer" }, - "country_of_residence": { - "type": "string", - "minLength": 1 - }, "created_at": { "type": "string" }, - "dietary_restrictions": { - "type": "array", - "items": { - "type": "string" - } - }, - "ethnicity": { - "type": "string", - "minLength": 1 - }, - "first_name": { - "type": "string", - "minLength": 1 - }, - "gender": { - "type": "string", - "minLength": 1 - }, - "github": { - "type": "string" - }, - "hackathons_attended_count": { - "type": "integer", - "minimum": 0 - }, - "heard_about": { - "type": "string", - "minLength": 1 - }, "id": { "type": "string" }, - "last_name": { - "type": "string", - "minLength": 1 - }, - "level_of_study": { - "type": "string", - "minLength": 1 - }, - "linkedin": { - "type": "string" - }, - "major": { - "type": "string", - "minLength": 1 - }, "opt_in_mlh_emails": { "type": "boolean" }, - "phone_e164": { - "type": "string" - }, - "race": { - "type": "string", - "minLength": 1 - }, "reject_votes": { "type": "integer" }, + "responses": { + "type": "array", + "items": { + "type": "integer" + } + }, "resume_path": { "type": "string" }, @@ -4665,30 +4524,12 @@ const docTemplate = `{ "reviews_completed": { "type": "integer" }, - "shirt_size": { - "type": "string", - "minLength": 1 - }, - "short_answer_responses": { - "type": "array", - "items": { - "type": "integer" - } - }, - "software_experience_level": { - "type": "string", - "minLength": 1 - }, "status": { "$ref": "#/definitions/store.ApplicationStatus" }, "submitted_at": { "type": "string" }, - "university": { - "type": "string", - "minLength": 1 - }, "updated_at": { "type": "string" }, @@ -4697,9 +4538,6 @@ const docTemplate = `{ }, "waitlist_votes": { "type": "integer" - }, - "website": { - "type": "string" } } }, @@ -4889,6 +4727,39 @@ const docTemplate = `{ } } }, + "store.ApplicationSchemaField": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "required": { + "type": "boolean" + }, + "section": { + "type": "string" + }, + "type": { + "type": "string" + }, + "validation": { + "type": "object", + "additionalProperties": true + } + } + }, "store.ApplicationStats": { "type": "object", "properties": { @@ -5101,32 +4972,6 @@ const docTemplate = `{ } } }, - "store.ShortAnswerQuestion": { - "type": "object", - "required": [ - "id", - "question" - ], - "properties": { - "display_order": { - "type": "integer", - "minimum": 0 - }, - "id": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "question": { - "type": "string", - "maxLength": 500, - "minLength": 1 - }, - "required": { - "type": "boolean" - } - } - }, "store.Sponsor": { "type": "object", "properties": { From 43b48a4aee7c5a50988432e7bec3f29f2cbe7077 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Mon, 13 Apr 2026 16:35:16 -0500 Subject: [PATCH 10/17] fix: seeding & applications in other models --- cmd/api/applications_test.go | 6 +-- internal/db/seed.go | 96 +++++++++++++++++++--------------- internal/store/applications.go | 14 ++--- internal/store/reviews.go | 14 ++--- internal/store/settings.go | 4 +- internal/store/users.go | 14 ++--- 6 files changed, 81 insertions(+), 67 deletions(-) diff --git a/cmd/api/applications_test.go b/cmd/api/applications_test.go index c9e94e57..8c958ff8 100644 --- a/cmd/api/applications_test.go +++ b/cmd/api/applications_test.go @@ -22,11 +22,11 @@ func newCompleteApplication(userID string) *store.Application { UserID: userID, Status: store.StatusDraft, Responses: json.RawMessage(`{ - "first_name":"John","last_name":"Doe","phone_e164":"+11234567890", + "first_name":"John","last_name":"Doe","phone":"+11234567890", "age":20,"country_of_residence":"US","gender":"Male","race":"Asian", "ethnicity":"Not Hispanic","university":"UT Dallas","major":"CS", - "level_of_study":"Undergraduate","hackathons_attended_count":2, - "software_experience_level":"Intermediate","heard_about":"Friend", + "level_of_study":"Undergraduate","hackathons_attended":2, + "experience_level":"Intermediate","heard_about":"Friend", "shirt_size":"M" }`), AckMLHCOC: true, diff --git a/internal/db/seed.go b/internal/db/seed.go index d20cbb78..95531141 100644 --- a/internal/db/seed.go +++ b/internal/db/seed.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/json" "fmt" "log" "math/rand" @@ -49,14 +50,7 @@ var ( expLevels = []string{"Beginner", "Intermediate", "Advanced", "Expert"} heardFrom = []string{"Social Media", "Friend", "Professor", "Career Fair", "Website", "Email"} countries = []string{"United States", "India", "Canada", "Mexico", "United Kingdom"} - dietaries = []string{"{}", "{}", "{}", "{halal}", "{vegetarian}", "{vegan}", "{nuts}", "{dairy}"} - - saqResponses = `{ - "saq_1": "I love building things and meeting new people!", - "saq_2": "I have attended 2 hackathons and learned a lot about teamwork.", - "saq_3": "I hope to learn new technologies and frameworks.", - "saq_4": "I am looking forward to the workshops and networking." - }` + dietaryOptions = []string{"Vegan", "Vegetarian", "Halal", "Nuts", "Fish", "Wheat", "Dairy", "Eggs", "No Beef", "No Pork"} reviewNotePool = []string{ "Strong technical background, good fit.", @@ -139,32 +133,30 @@ func seedUsers(db *sql.DB, hackerCount int) (adminIDs, hackerIDs []string) { return adminIDs, hackerIDs } +func pickDietaryRestrictions() []string { + // ~40% chance of no restrictions + if rng.Intn(5) < 2 { + return []string{} + } + // Pick 1-2 random restrictions + n := 1 + rng.Intn(2) + perm := rng.Perm(len(dietaryOptions)) + result := make([]string, n) + for i := 0; i < n; i++ { + result[i] = dietaryOptions[perm[i]] + } + return result +} + func seedApplications(db *sql.DB, hackerIDs []string) (appIDs, appStatuses []string) { tx := mustBegin(db) query := ` INSERT INTO applications ( - user_id, status, - first_name, last_name, phone_e164, age, - country_of_residence, gender, race, ethnicity, - university, major, level_of_study, - short_answer_responses, - hackathons_attended_count, software_experience_level, heard_about, - shirt_size, dietary_restrictions, accommodations, - github, linkedin, - ack_application, ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, - submitted_at, ai_percent - ) VALUES ( - $1, $2, - $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13, - $14, - $15, $16, $17, - $18, $19, $20, - $21, $22, - $23, $24, $25, $26, - $27, NULL - ) RETURNING id + user_id, status, responses, + ack_mlh_coc, ack_mlh_privacy, opt_in_mlh_emails, + submitted_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id ` for i, userID := range hackerIDs { @@ -179,18 +171,40 @@ func seedApplications(db *sql.DB, hackerIDs []string) (appIDs, appStatuses []str submittedAt = ptr(randomPastTime(30)) } + responses := map[string]any{ + "first_name": first, + "last_name": last, + "phone": fmt.Sprintf("+1214555%04d", i%10000), + "age": 18 + rng.Intn(10), + "country_of_residence": pick(countries), + "gender": pick(genders), + "race": "Asian", + "ethnicity": "Hispanic", + "university": pick(universities), + "major": pick(majors), + "level_of_study": pick(levels), + "hackathons_attended": rng.Intn(6), + "experience_level": pick(expLevels), + "heard_about": pick(heardFrom), + "shirt_size": pick(shirtSizes), + "dietary_restrictions": pickDietaryRestrictions(), + "github": fmt.Sprintf("https://github.com/%s%s%d", first, last, i), + "linkedin": fmt.Sprintf("https://linkedin.com/in/%s%s%d", first, last, i), + "saq_1": "I love building things and meeting new people!", + "saq_2": "I have attended 2 hackathons and learned a lot about teamwork.", + "saq_3": "I hope to learn new technologies and frameworks.", + "saq_4": "I am looking forward to the workshops and networking.", + } + + responsesJSON, err := json.Marshal(responses) + if err != nil { + log.Fatalf("failed to marshal responses for application %d: %v", i, err) + } + var id string - err := tx.QueryRow(query, - userID, status, - first, last, fmt.Sprintf("+1214555%04d", i%10000), int16(18+rng.Intn(10)), - pick(countries), pick(genders), "Asian", "Hispanic", - pick(universities), pick(majors), pick(levels), - saqResponses, - int16(rng.Intn(6)), pick(expLevels), pick(heardFrom), - pick(shirtSizes), pick(dietaries), nil, - fmt.Sprintf("https://github.com/%s%s%d", first, last, i), - fmt.Sprintf("https://linkedin.com/in/%s%s%d", first, last, i), - submitted, submitted, submitted, rng.Intn(2) == 0, + err = tx.QueryRow(query, + userID, status, responsesJSON, + submitted, submitted, rng.Intn(2) == 0, submittedAt, ).Scan(&id) if err != nil { diff --git a/internal/store/applications.go b/internal/store/applications.go index 12c33450..21f5b509 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -61,14 +61,14 @@ type ApplicationListItem struct { Status ApplicationStatus `json:"status"` FirstName *string `json:"first_name"` LastName *string `json:"last_name"` - PhoneE164 *string `json:"phone_e164"` + Phone *string `json:"phone"` Age *int16 `json:"age"` CountryOfResidence *string `json:"country_of_residence"` Gender *string `json:"gender"` University *string `json:"university"` Major *string `json:"major"` LevelOfStudy *string `json:"level_of_study"` - HackathonsAttendedCount *int16 `json:"hackathons_attended_count"` + HackathonsAttended *int16 `json:"hackathons_attended"` SubmittedAt *time.Time `json:"submitted_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -369,14 +369,14 @@ func (s *ApplicationsStore) List( SELECT a.id, a.user_id, u.email, a.status, a.responses->>'first_name' AS first_name, a.responses->>'last_name' AS last_name, - a.responses->>'phone_e164' AS phone_e164, - (a.responses->>'age')::smallint AS age, + a.responses->>'phone' AS phone, + NULLIF(a.responses->>'age', '')::smallint AS age, a.responses->>'country_of_residence' AS country_of_residence, a.responses->>'gender' AS gender, a.responses->>'university' AS university, a.responses->>'major' AS major, a.responses->>'level_of_study' AS level_of_study, - (a.responses->>'hackathons_attended_count')::smallint AS hackathons_attended_count, + NULLIF(a.responses->>'hackathons_attended', '')::smallint AS hackathons_attended, a.submitted_at, a.created_at, a.updated_at, a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent, a.resume_path IS NOT NULL AS has_resume @@ -468,10 +468,10 @@ func (s *ApplicationsStore) List( var item ApplicationListItem if err := rows.Scan( &item.ID, &item.UserID, &item.Email, &item.Status, - &item.FirstName, &item.LastName, &item.PhoneE164, &item.Age, + &item.FirstName, &item.LastName, &item.Phone, &item.Age, &item.CountryOfResidence, &item.Gender, &item.University, &item.Major, &item.LevelOfStudy, - &item.HackathonsAttendedCount, + &item.HackathonsAttended, &item.SubmittedAt, &item.CreatedAt, &item.UpdatedAt, &item.AcceptVotes, &item.RejectVotes, &item.WaitlistVotes, &item.ReviewsAssigned, &item.ReviewsCompleted, &item.AIPercent, &item.HasResume, diff --git a/internal/store/reviews.go b/internal/store/reviews.go index 8a2deb14..7856ce45 100644 --- a/internal/store/reviews.go +++ b/internal/store/reviews.go @@ -41,7 +41,7 @@ type ApplicationReviewWithDetails struct { University *string `json:"university"` Major *string `json:"major"` CountryOfResidence *string `json:"country_of_residence"` - HackathonsAttendedCount *int16 `json:"hackathons_attended_count"` + HackathonsAttended *int16 `json:"hackathons_attended"` } // ReviewNote represents a note from an admin review (without vote information) @@ -97,10 +97,10 @@ func (s *ApplicationReviewsStore) GetPendingByAdminID(ctx context.Context, admin ar.id, ar.application_id, ar.admin_id, ar.vote, ar.notes, ar.assigned_at, ar.reviewed_at, ar.created_at, ar.updated_at, a.responses->>'first_name', a.responses->>'last_name', u.email, - (a.responses->>'age')::smallint, + NULLIF(a.responses->>'age', '')::smallint, a.responses->>'university', a.responses->>'major', a.responses->>'country_of_residence', - (a.responses->>'hackathons_attended_count')::smallint + NULLIF(a.responses->>'hackathons_attended', '')::smallint FROM application_reviews ar JOIN applications a ON ar.application_id = a.id JOIN users u ON a.user_id = u.id @@ -123,7 +123,7 @@ func (s *ApplicationReviewsStore) GetPendingByAdminID(ctx context.Context, admin &review.AssignedAt, &review.ReviewedAt, &review.CreatedAt, &review.UpdatedAt, &review.FirstName, &review.LastName, &review.Email, &review.Age, - &review.University, &review.Major, &review.CountryOfResidence, &review.HackathonsAttendedCount, + &review.University, &review.Major, &review.CountryOfResidence, &review.HackathonsAttended, ); err != nil { return nil, err } @@ -148,10 +148,10 @@ func (s *ApplicationReviewsStore) GetCompletedByAdminID(ctx context.Context, adm ar.id, ar.application_id, ar.admin_id, ar.vote, ar.notes, ar.assigned_at, ar.reviewed_at, ar.created_at, ar.updated_at, a.responses->>'first_name', a.responses->>'last_name', u.email, - (a.responses->>'age')::smallint, + NULLIF(a.responses->>'age', '')::smallint, a.responses->>'university', a.responses->>'major', a.responses->>'country_of_residence', - (a.responses->>'hackathons_attended_count')::smallint + NULLIF(a.responses->>'hackathons_attended', '')::smallint FROM application_reviews ar JOIN applications a ON ar.application_id = a.id JOIN users u ON a.user_id = u.id @@ -174,7 +174,7 @@ func (s *ApplicationReviewsStore) GetCompletedByAdminID(ctx context.Context, adm &review.AssignedAt, &review.ReviewedAt, &review.CreatedAt, &review.UpdatedAt, &review.FirstName, &review.LastName, &review.Email, &review.Age, - &review.University, &review.Major, &review.CountryOfResidence, &review.HackathonsAttendedCount, + &review.University, &review.Major, &review.CountryOfResidence, &review.HackathonsAttended, ); err != nil { return nil, err } diff --git a/internal/store/settings.go b/internal/store/settings.go index a3f8c91f..18d5f975 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -87,7 +87,7 @@ func (s *SettingsStore) UpdateApplicationSchema(ctx context.Context, fields []Ap query := ` INSERT INTO settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` _, err = s.db.ExecContext(ctx, query, SettingsKeyApplicationSchema, string(value)) @@ -183,7 +183,7 @@ func (s *SettingsStore) UpdateScanTypes(ctx context.Context, scanTypes []ScanTyp query := ` INSERT INTO settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` _, err = s.db.ExecContext(ctx, query, SettingsKeyScanTypes, value) diff --git a/internal/store/users.go b/internal/store/users.go index ed7c5541..ea2fc477 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -261,8 +261,8 @@ func (s *UsersStore) Search(ctx context.Context, query string, limit int, offset FROM users u LEFT JOIN applications a ON a.user_id = u.id WHERE u.email ILIKE '%' || $1 || '%' - OR a.first_name ILIKE '%' || $1 || '%' - OR a.last_name ILIKE '%' || $1 || '%' + OR a.responses->>'first_name' ILIKE '%' || $1 || '%' + OR a.responses->>'last_name' ILIKE '%' || $1 || '%' ` var totalCount int @@ -271,12 +271,12 @@ func (s *UsersStore) Search(ctx context.Context, query string, limit int, offset } searchQuery := ` - SELECT u.id, u.email, u.role, a.first_name, a.last_name, u.profile_picture_url, u.created_at + SELECT u.id, u.email, u.role, a.responses->>'first_name', a.responses->>'last_name', u.profile_picture_url, u.created_at FROM users u LEFT JOIN applications a ON a.user_id = u.id WHERE u.email ILIKE '%' || $1 || '%' - OR a.first_name ILIKE '%' || $1 || '%' - OR a.last_name ILIKE '%' || $1 || '%' + OR a.responses->>'first_name' ILIKE '%' || $1 || '%' + OR a.responses->>'last_name' ILIKE '%' || $1 || '%' ORDER BY u.created_at DESC LIMIT $2 OFFSET $3 ` @@ -446,7 +446,7 @@ func (s *UsersStore) ListUsers(ctx context.Context, filters UserListFilters, cur if filters.Search != "" { searchParam := "%" + filters.Search + "%" conditions = append(conditions, fmt.Sprintf( - "(u.email ILIKE $%d OR a.first_name ILIKE $%d OR a.last_name ILIKE $%d)", + "(u.email ILIKE $%d OR a.responses->>'first_name' ILIKE $%d OR a.responses->>'last_name' ILIKE $%d)", paramIdx, paramIdx, paramIdx, )) args = append(args, searchParam) @@ -490,7 +490,7 @@ func (s *UsersStore) ListUsers(ctx context.Context, filters UserListFilters, cur } query := fmt.Sprintf(` - SELECT u.id, u.email, u.role, a.first_name, a.last_name, u.profile_picture_url, u.created_at + SELECT u.id, u.email, u.role, a.responses->>'first_name', a.responses->>'last_name', u.profile_picture_url, u.created_at FROM users u LEFT JOIN applications a ON a.user_id = u.id %s From f3649ba5ece59ddfe323f1333563514d4e3e72b8 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Mon, 13 Apr 2026 17:09:06 -0500 Subject: [PATCH 11/17] feat: migrate to new migrations & update sa/applications UI --- .../application/ApplicationPage.tsx | 171 ++++------- .../src/pages/superadmin/application/api.ts | 30 +- .../application/components/AddFieldDialog.tsx | 197 ++++++++++++ .../components/ApplicationPreview.tsx | 237 ++++++--------- .../application/components/FieldCard.tsx | 285 ++++++++++++++++++ .../application/components/OptionsEditor.tsx | 58 ++++ .../application/components/SchemaEditor.tsx | 81 +++++ .../pages/superadmin/application/constants.ts | 39 +++ .../src/pages/superadmin/application/store.ts | 129 +++++--- client/web/src/types.ts | 28 ++ .../migrations/000001_create_users.down.sql | 5 - .../migrations/000001_create_users.up.sql | 17 -- .../000001_extensions_and_functions.down.sql | 0 .../000001_extensions_and_functions.up.sql | 0 .../000002_create_users.down.sql | 0 .../000002_create_users.up.sql | 0 .../000002_updated_at_trigger.down.sql | 3 - .../000002_updated_at_trigger.up.sql | 15 - .../000003_add_auth_method.down.sql | 2 - .../migrations/000003_add_auth_method.up.sql | 7 - .../000003_create_settings.down.sql | 0 .../000003_create_settings.up.sql | 0 .../000004_create_applications.down.sql | 15 - .../000004_create_applications.up.sql | 82 ----- .../000004_seed_settings.down.sql | 0 .../000004_seed_settings.up.sql | 0 ...add_applications_pagination_index.down.sql | 1 - ...5_add_applications_pagination_index.up.sql | 2 - .../000005_create_applications.down.sql | 0 .../000005_create_applications.up.sql | 0 ...000006_create_application_reviews.down.sql | 0 .../000006_create_application_reviews.up.sql | 0 .../000006_create_settings.down.sql | 1 - .../migrations/000006_create_settings.up.sql | 7 - .../000007_add_dynamic_questions.down.sql | 1 - .../000007_add_dynamic_questions.up.sql | 11 - .../000007_create_vote_count_trigger.down.sql | 0 .../000007_create_vote_count_trigger.up.sql | 0 .../000008_add_profile_picture_url.down.sql | 1 - .../000008_add_profile_picture_url.up.sql | 1 - .../000008_create_scans.down.sql | 0 .../000008_create_scans.up.sql | 0 ...d_reviews_per_application_setting.down.sql | 1 - ...add_reviews_per_application_setting.up.sql | 2 - .../000009_create_schedule.down.sql | 0 .../000009_create_schedule.up.sql | 0 ...000010_create_application_reviews.down.sql | 14 - .../000010_create_application_reviews.up.sql | 49 --- .../000010_create_sponsors.down.sql | 0 .../000010_create_sponsors.up.sql | 0 ...00011_add_application_vote_counts.down.sql | 8 - .../000011_add_application_vote_counts.up.sql | 10 - .../000012_add_vote_count_trigger.down.sql | 3 - .../000012_add_vote_count_trigger.up.sql | 82 ----- .../migrations/000013_create_scans.down.sql | 3 - .../migrations/000013_create_scans.up.sql | 15 - .../000014_add_scan_types_setting.down.sql | 1 - .../000014_add_scan_types_setting.up.sql | 3 - .../000015_seed_scan_stats.down.sql | 1 - .../migrations/000015_seed_scan_stats.up.sql | 3 - ..._insert_review_assignment_setting.down.sql | 1 - ...16_insert_review_assignment_setting.up.sql | 14 - .../migrations/000017_add_ai_percent.down.sql | 2 - .../migrations/000017_add_ai_percent.up.sql | 6 - .../000018_add_search_indexes.down.sql | 5 - .../000018_add_search_indexes.up.sql | 5 - .../000019_create_schedule.down.sql | 1 - .../migrations/000019_create_schedule.up.sql | 17 -- .../000020_add_resume_path.down.sql | 1 - .../migrations/000020_add_resume_path.up.sql | 1 - ...1_add_admin_schedule_edit_setting.down.sql | 1 - ...021_add_admin_schedule_edit_setting.up.sql | 3 - ..._add_hackathon_date_range_setting.down.sql | 1 - ...22_add_hackathon_date_range_setting.up.sql | 3 - .../000023_create_sponsors.down.sql | 2 - .../migrations/000023_create_sponsors.up.sql | 15 - .../000024_sponsor_logo_base64.down.sql | 3 - .../000024_sponsor_logo_base64.up.sql | 3 - ...eate_applications_enabled_setting.down.sql | 1 - ...create_applications_enabled_setting.up.sql | 2 - docs/docs.go | 6 +- 81 files changed, 924 insertions(+), 790 deletions(-) create mode 100644 client/web/src/pages/superadmin/application/components/AddFieldDialog.tsx create mode 100644 client/web/src/pages/superadmin/application/components/FieldCard.tsx create mode 100644 client/web/src/pages/superadmin/application/components/OptionsEditor.tsx create mode 100644 client/web/src/pages/superadmin/application/components/SchemaEditor.tsx create mode 100644 client/web/src/pages/superadmin/application/constants.ts delete mode 100644 cmd/migrate/migrations/000001_create_users.down.sql delete mode 100644 cmd/migrate/migrations/000001_create_users.up.sql rename cmd/migrate/{new-migrations => migrations}/000001_extensions_and_functions.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000001_extensions_and_functions.up.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000002_create_users.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000002_create_users.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000002_updated_at_trigger.down.sql delete mode 100644 cmd/migrate/migrations/000002_updated_at_trigger.up.sql delete mode 100644 cmd/migrate/migrations/000003_add_auth_method.down.sql delete mode 100644 cmd/migrate/migrations/000003_add_auth_method.up.sql rename cmd/migrate/{new-migrations => migrations}/000003_create_settings.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000003_create_settings.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000004_create_applications.down.sql delete mode 100644 cmd/migrate/migrations/000004_create_applications.up.sql rename cmd/migrate/{new-migrations => migrations}/000004_seed_settings.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000004_seed_settings.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000005_add_applications_pagination_index.down.sql delete mode 100644 cmd/migrate/migrations/000005_add_applications_pagination_index.up.sql rename cmd/migrate/{new-migrations => migrations}/000005_create_applications.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000005_create_applications.up.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000006_create_application_reviews.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000006_create_application_reviews.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000006_create_settings.down.sql delete mode 100644 cmd/migrate/migrations/000006_create_settings.up.sql delete mode 100644 cmd/migrate/migrations/000007_add_dynamic_questions.down.sql delete mode 100644 cmd/migrate/migrations/000007_add_dynamic_questions.up.sql rename cmd/migrate/{new-migrations => migrations}/000007_create_vote_count_trigger.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000007_create_vote_count_trigger.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000008_add_profile_picture_url.down.sql delete mode 100644 cmd/migrate/migrations/000008_add_profile_picture_url.up.sql rename cmd/migrate/{new-migrations => migrations}/000008_create_scans.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000008_create_scans.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000009_add_reviews_per_application_setting.down.sql delete mode 100644 cmd/migrate/migrations/000009_add_reviews_per_application_setting.up.sql rename cmd/migrate/{new-migrations => migrations}/000009_create_schedule.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000009_create_schedule.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000010_create_application_reviews.down.sql delete mode 100644 cmd/migrate/migrations/000010_create_application_reviews.up.sql rename cmd/migrate/{new-migrations => migrations}/000010_create_sponsors.down.sql (100%) rename cmd/migrate/{new-migrations => migrations}/000010_create_sponsors.up.sql (100%) delete mode 100644 cmd/migrate/migrations/000011_add_application_vote_counts.down.sql delete mode 100644 cmd/migrate/migrations/000011_add_application_vote_counts.up.sql delete mode 100644 cmd/migrate/migrations/000012_add_vote_count_trigger.down.sql delete mode 100644 cmd/migrate/migrations/000012_add_vote_count_trigger.up.sql delete mode 100644 cmd/migrate/migrations/000013_create_scans.down.sql delete mode 100644 cmd/migrate/migrations/000013_create_scans.up.sql delete mode 100644 cmd/migrate/migrations/000014_add_scan_types_setting.down.sql delete mode 100644 cmd/migrate/migrations/000014_add_scan_types_setting.up.sql delete mode 100644 cmd/migrate/migrations/000015_seed_scan_stats.down.sql delete mode 100644 cmd/migrate/migrations/000015_seed_scan_stats.up.sql delete mode 100644 cmd/migrate/migrations/000016_insert_review_assignment_setting.down.sql delete mode 100644 cmd/migrate/migrations/000016_insert_review_assignment_setting.up.sql delete mode 100644 cmd/migrate/migrations/000017_add_ai_percent.down.sql delete mode 100644 cmd/migrate/migrations/000017_add_ai_percent.up.sql delete mode 100644 cmd/migrate/migrations/000018_add_search_indexes.down.sql delete mode 100644 cmd/migrate/migrations/000018_add_search_indexes.up.sql delete mode 100644 cmd/migrate/migrations/000019_create_schedule.down.sql delete mode 100644 cmd/migrate/migrations/000019_create_schedule.up.sql delete mode 100644 cmd/migrate/migrations/000020_add_resume_path.down.sql delete mode 100644 cmd/migrate/migrations/000020_add_resume_path.up.sql delete mode 100644 cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.down.sql delete mode 100644 cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.up.sql delete mode 100644 cmd/migrate/migrations/000022_add_hackathon_date_range_setting.down.sql delete mode 100644 cmd/migrate/migrations/000022_add_hackathon_date_range_setting.up.sql delete mode 100644 cmd/migrate/migrations/000023_create_sponsors.down.sql delete mode 100644 cmd/migrate/migrations/000023_create_sponsors.up.sql delete mode 100644 cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql delete mode 100644 cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql delete mode 100644 cmd/migrate/migrations/000025_create_applications_enabled_setting.down.sql delete mode 100644 cmd/migrate/migrations/000025_create_applications_enabled_setting.up.sql diff --git a/client/web/src/pages/superadmin/application/ApplicationPage.tsx b/client/web/src/pages/superadmin/application/ApplicationPage.tsx index df1dd6e5..47274c08 100644 --- a/client/web/src/pages/superadmin/application/ApplicationPage.tsx +++ b/client/web/src/pages/superadmin/application/ApplicationPage.tsx @@ -1,4 +1,4 @@ -import { Loader2, Plus, Save, Trash2 } from "lucide-react"; +import { Loader2, Save } from "lucide-react"; import { useEffect } from "react"; import { @@ -19,30 +19,20 @@ import { CardDescription, CardHeader, } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { ApplicationPreview } from "./components/ApplicationPreview"; -import { useApplicationSettingsStore } from "./store"; +import { SchemaEditor } from "./components/SchemaEditor"; +import { useApplicationSchemaStore } from "./store"; export default function ApplicationPage() { - const { - questions, - loading, - saving, - fetchQuestions, - saveQuestions, - updateQuestion, - addQuestion, - removeQuestion, - } = useApplicationSettingsStore(); + const { fields, loading, saving, fetchSchema, saveSchema } = + useApplicationSchemaStore(); useEffect(() => { const controller = new AbortController(); - fetchQuestions(controller.signal); + fetchSchema(controller.signal); return () => controller.abort(); - }, [fetchQuestions]); + }, [fetchSchema]); return (
@@ -54,119 +44,60 @@ export default function ApplicationPage() { - + - {/* Right: SAQ Editor */} + {/* Right: Schema Editor */} - Short Answer Questions + Application Schema -
-

- Configure the short answer questions that appear on hacker - applications. -

+ {loading ? ( +
+ +
+ ) : ( +
+ - {loading ? ( -
- -
- ) : ( -
- {questions.map((q, index) => ( -
-
- - Q{index + 1} - -