Resume (Optional)
- Upload a PDF up to 5 MB.
+ Upload a PDF up to {MAX_RESUME_SIZE_MB} MB.
diff --git a/client/web/src/pages/hacker/apply/validations.ts b/client/web/src/pages/hacker/apply/validations.ts
index 38f9456e..f8208056 100644
--- a/client/web/src/pages/hacker/apply/validations.ts
+++ b/client/web/src/pages/hacker/apply/validations.ts
@@ -1,138 +1,14 @@
-import { z } from "zod";
+import { buildZodSchema } from "@/shared/lib/schema-utils";
+import type { ApplicationSchemaField } from "@/types";
-// Step 1: Personal Info
-export const personalInfoSchema = z.object({
- first_name: z.string().min(1, "First name is required"),
- last_name: z.string().min(1, "Last name is required"),
- phone_e164: z
- .string()
- .min(1, "Phone number is required")
- .regex(
- /^\+[1-9]\d{1,14}$/,
- "Phone must be in E.164 format (e.g., +12025551234)",
- ),
- age: z.coerce
- .number({ error: "Age is required" })
- .int("Age must be a whole number")
- .min(1, "Age is required")
- .max(150, "Age must be 150 or less"),
- country_of_residence: z.string().min(1, "Country is required"),
- gender: z.string().min(1, "Gender is required"),
- race: z.string().min(1, "Race is required"),
- ethnicity: z.string().min(1, "Ethnicity is required"),
-});
+/**
+ * Build the full application form schema from the dynamic application_schema.
+ */
+export function buildApplicationSchema(fields: ApplicationSchemaField[]) {
+ return buildZodSchema(fields);
+}
-// Step 2: School Info
-export const schoolInfoSchema = z.object({
- university: z.string().min(1, "University is required"),
- major: z.string().min(1, "Major is required"),
- level_of_study: z.string().min(1, "Level of study is required"),
-});
-
-// Step 3: Hackathon Experience
-export const experienceSchema = z.object({
- hackathons_attended_count: z.coerce
- .number({ error: "Number of hackathons is required" })
- .int("Must be a whole number")
- .min(0, "Must be 0 or more"),
- software_experience_level: z.string().min(1, "Experience level is required"),
- heard_about: z.string().min(1, "This field is required"),
-});
-
-// Step 4: Short Answers (dynamic questions - validation at submit time)
-export const shortAnswerSchema = z.object({
- short_answer_responses: z.record(z.string(), z.string()).default({}),
-});
-
-// Step 5: Event Info
-export const eventInfoSchema = z.object({
- shirt_size: z.string().min(1, "Shirt size is required"),
- dietary_restrictions: z
- .array(
- z.enum([
- "vegan",
- "vegetarian",
- "halal",
- "nuts",
- "fish",
- "wheat",
- "dairy",
- "eggs",
- "no_beef",
- "no_pork",
- ]),
- )
- .optional()
- .default([]),
- accommodations: z.string().optional().default(""),
-});
-
-// Step 6: Sponsor Info (all optional)
-export const sponsorInfoSchema = z.object({
- github: z.url("Must be a valid URL").optional().or(z.literal("")),
- linkedin: z.url("Must be a valid URL").optional().or(z.literal("")),
- website: z.url("Must be a valid URL").optional().or(z.literal("")),
-});
-
-// Step 7: Review - Acknowledgments
-export const acknowledgmentsSchema = z.object({
- ack_application: z.boolean().refine((val) => val === true, {
- message: "You must acknowledge this disclaimer",
- }),
- ack_mlh_coc: z.boolean().refine((val) => val === true, {
- message: "You must agree to the MLH Code of Conduct",
- }),
- ack_mlh_privacy: z.boolean().refine((val) => val === true, {
- message: "You must authorize data sharing with MLH",
- }),
- opt_in_mlh_emails: z.boolean().optional().default(false),
-});
-
-// Combined schema for full form
-export const applicationSchema = z.object({
- ...personalInfoSchema.shape,
- ...schoolInfoSchema.shape,
- ...experienceSchema.shape,
- ...shortAnswerSchema.shape,
- ...eventInfoSchema.shape,
- ...sponsorInfoSchema.shape,
- ...acknowledgmentsSchema.shape,
-});
-
-export type ApplicationFormData = z.infer
;
-
-// Step field mappings for partial validation
-export const STEP_FIELDS: Record = {
- 0: [
- "first_name",
- "last_name",
- "phone_e164",
- "age",
- "country_of_residence",
- "gender",
- "race",
- "ethnicity",
- ],
- 1: ["university", "major", "level_of_study"],
- 2: ["hackathons_attended_count", "software_experience_level", "heard_about"],
- 3: ["short_answer_responses"],
- 4: ["shirt_size", "dietary_restrictions", "accommodations"],
- 5: ["github", "linkedin", "website"],
- 6: ["ack_application", "ack_mlh_coc", "ack_mlh_privacy", "opt_in_mlh_emails"],
-};
-
-// Step schemas for per-step validation
-export const stepSchemas = [
- personalInfoSchema,
- schoolInfoSchema,
- experienceSchema,
- shortAnswerSchema,
- eventInfoSchema,
- sponsorInfoSchema,
- acknowledgmentsSchema,
-];
-
-// Select options
+// Select options — provide human-readable labels for field values
export const GENDER_OPTIONS = [
{ value: "male", label: "Male" },
{ value: "female", label: "Female" },
@@ -209,7 +85,6 @@ export const HEARD_ABOUT_OPTIONS = [
{ value: "other", label: "Other" },
];
-// Country list (common countries at top)
export const COUNTRY_OPTIONS = [
{ value: "US", label: "United States" },
{ value: "CA", label: "Canada" },
diff --git a/client/web/src/pages/hacker/status/StatusPage.tsx b/client/web/src/pages/hacker/status/StatusPage.tsx
index 89598c21..bc90b1ba 100644
--- a/client/web/src/pages/hacker/status/StatusPage.tsx
+++ b/client/web/src/pages/hacker/status/StatusPage.tsx
@@ -149,17 +149,20 @@ export default function StatusPage() {
Name:
- {application.first_name} {application.last_name}
+ {String(application.responses?.first_name ?? "")}{" "}
+ {String(application.responses?.last_name ?? "")}
University:
- {application.university || "Not provided"}
+ {String(
+ application.responses?.university || "Not provided",
+ )}
Major:
- {application.major || "Not provided"}
+ {String(application.responses?.major || "Not provided")}
{application.submitted_at && (
diff --git a/client/web/src/pages/superadmin/application/ApplicationPage.tsx b/client/web/src/pages/superadmin/application/ApplicationPage.tsx
index df1dd6e5..457a3bfa 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, sections, 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,62 @@ 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}
-
-
-
-
-
- updateQuestion(index, "required", checked === true)
- }
- className="cursor-pointer"
- />
-
- Required
-
-
-
removeQuestion(index)}
- className="text-muted-foreground hover:text-red-500 cursor-pointer h-8 w-8 p-0"
- >
-
-
-
-
- ))}
-
-
-
- Add Question
-
-
-
-
-
- {saving ? (
-
- ) : (
-
- )}
- Save Questions
-
-
-
-
- Save questions?
-
- This will affect all hacker
- applications. Are you sure you want to save these
- changes?
-
-
-
-
- Cancel
-
-
- Save
-
-
-
-
-
- )}
-
+
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ Save Schema
+
+
+
+
+
+ Save application schema?
+
+
+ This will affect all hacker applications.
+ Are you sure you want to save these changes?
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+ )}
diff --git a/client/web/src/pages/superadmin/application/api.ts b/client/web/src/pages/superadmin/application/api.ts
index b6f62846..90fc3774 100644
--- a/client/web/src/pages/superadmin/application/api.ts
+++ b/client/web/src/pages/superadmin/application/api.ts
@@ -1,26 +1,26 @@
import { getRequest, putRequest } from "@/shared/lib/api";
-import type { ApiResponse, ShortAnswerQuestion } from "@/types";
+import type { ApiResponse, ApplicationSchemaField } from "@/types";
-interface SAQuestionsResponse {
- questions: ShortAnswerQuestion[];
+interface ApplicationSchemaResponse {
+ fields: ApplicationSchemaField[];
}
-export async function fetchSAQuestions(
+export async function fetchApplicationSchema(
signal?: AbortSignal,
-): Promise
> {
- return getRequest(
- "/superadmin/settings/saquestions",
- "short answer questions",
+): Promise> {
+ return getRequest(
+ "/superadmin/settings/application-schema",
+ "application schema",
signal,
);
}
-export async function saveSAQuestions(
- questions: ShortAnswerQuestion[],
-): Promise> {
- return putRequest(
- "/superadmin/settings/saquestions",
- { questions },
- "short answer questions",
+export async function saveApplicationSchema(
+ fields: ApplicationSchemaField[],
+): Promise> {
+ return putRequest(
+ "/superadmin/settings/application-schema",
+ { fields },
+ "application schema",
);
}
diff --git a/client/web/src/pages/superadmin/application/components/AddFieldDialog.tsx b/client/web/src/pages/superadmin/application/components/AddFieldDialog.tsx
new file mode 100644
index 00000000..e8e1de60
--- /dev/null
+++ b/client/web/src/pages/superadmin/application/components/AddFieldDialog.tsx
@@ -0,0 +1,189 @@
+import { Plus } from "lucide-react";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import type { ApplicationSchemaField, FieldType } from "@/types";
+
+import { FIELD_TYPE_LABELS } from "../constants";
+import { useApplicationSchemaStore } from "../store";
+import { OptionsEditor } from "./OptionsEditor";
+
+const FIELD_TYPES: FieldType[] = [
+ "text",
+ "number",
+ "textarea",
+ "select",
+ "multi_select",
+ "checkbox",
+ "phone",
+];
+
+interface AddFieldDialogProps {
+ defaultSection?: string;
+}
+
+export function AddFieldDialog({ defaultSection }: AddFieldDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [section, setSection] = useState(defaultSection ?? "");
+ const [type, setType] = useState("text");
+ const [label, setLabel] = useState("");
+ const [required, setRequired] = useState(false);
+ const [options, setOptions] = useState([""]);
+
+ const fields = useApplicationSchemaStore((s) => s.fields);
+ const sections = useApplicationSchemaStore((s) => s.sections);
+ const addField = useApplicationSchemaStore((s) => s.addField);
+
+ const hasOptions = type === "select" || type === "multi_select";
+
+ const reset = () => {
+ setSection(defaultSection ?? sections[0]?.id ?? "");
+ setType("text");
+ setLabel("");
+ setRequired(false);
+ setOptions([""]);
+ };
+
+ const handleAdd = () => {
+ if (!label.trim()) return;
+
+ const sectionFields = fields.filter((f) => f.section === section);
+ const maxOrder = sectionFields.reduce(
+ (max, f) => Math.max(max, f.display_order),
+ 0,
+ );
+
+ const sectionDef = sections.find((s) => s.id === section);
+
+ const field: ApplicationSchemaField = {
+ id: `field_${Date.now()}`,
+ type,
+ label: label.trim(),
+ required,
+ section,
+ section_label: sectionDef?.label,
+ display_order: maxOrder + 1,
+ ...(hasOptions ? { options: options.filter((o) => o.trim()) } : {}),
+ };
+
+ addField(field);
+ reset();
+ setOpen(false);
+ };
+
+ return (
+ {
+ setOpen(v);
+ if (v) reset();
+ }}
+ >
+
+
+
+ Add Field
+
+
+
+
+ Add Field
+
+
+ {/* Section */}
+
+ Section
+
+
+
+
+
+ {sections.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+ {/* Type */}
+
+ Field Type
+ setType(v)}>
+
+
+
+
+ {FIELD_TYPES.map((t) => (
+
+ {FIELD_TYPE_LABELS[t]}
+
+ ))}
+
+
+
+
+ {/* Label */}
+
+ Label
+ setLabel(e.target.value)}
+ placeholder="e.g. Favorite programming language"
+ />
+
+
+ {/* Required */}
+
+
+ Required
+
+
+ {/* Options for select types */}
+ {hasOptions && (
+
+ )}
+
+
+
+ Add Field
+
+
+
+
+ );
+}
diff --git a/client/web/src/pages/superadmin/application/components/AddSectionDialog.tsx b/client/web/src/pages/superadmin/application/components/AddSectionDialog.tsx
new file mode 100644
index 00000000..3438c74f
--- /dev/null
+++ b/client/web/src/pages/superadmin/application/components/AddSectionDialog.tsx
@@ -0,0 +1,77 @@
+import { Plus } from "lucide-react";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { useApplicationSchemaStore } from "../store";
+
+export function AddSectionDialog() {
+ const [open, setOpen] = useState(false);
+ const [label, setLabel] = useState("");
+ const addSection = useApplicationSchemaStore((s) => s.addSection);
+
+ const handleAdd = () => {
+ if (!label.trim()) return;
+ addSection(label.trim());
+ setLabel("");
+ setOpen(false);
+ };
+
+ return (
+ {
+ setOpen(v);
+ if (v) setLabel("");
+ }}
+ >
+
+
+
+ Add Section
+
+
+
+
+ Add Section
+
+
+
+ Section Name
+ setLabel(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleAdd();
+ }}
+ placeholder="e.g. Travel Information"
+ />
+
+
+
+
+ Add Section
+
+
+
+
+ );
+}
diff --git a/client/web/src/pages/superadmin/application/components/ApplicationPreview.tsx b/client/web/src/pages/superadmin/application/components/ApplicationPreview.tsx
index faaa8b7e..16498760 100644
--- a/client/web/src/pages/superadmin/application/components/ApplicationPreview.tsx
+++ b/client/web/src/pages/superadmin/application/components/ApplicationPreview.tsx
@@ -1,9 +1,18 @@
-import { useEffect, useRef } from "react";
+import { Trash2, Upload } from "lucide-react";
-import type { ShortAnswerQuestion } from "@/types";
+import { Button } from "@/components/ui/button";
+import {
+ groupFieldsBySection,
+ renderLabel,
+ type SectionDef,
+} from "@/shared/lib/schema-utils";
+import type { ApplicationSchemaField } from "@/types";
+
+const RESUME_PREVIEW_MAX_MB = 5;
interface ApplicationPreviewProps {
- questions: ShortAnswerQuestion[];
+ fields: ApplicationSchemaField[];
+ sections: SectionDef[];
}
function PreviewSection({
@@ -72,187 +81,135 @@ function PreviewCheckbox({ label }: { label: string }) {
return (
-
{label}
+
{renderLabel(label)}
);
}
-const DIETARY_OPTIONS = [
- "Vegan",
- "Vegetarian",
- "Halal",
- "Nut Allergy",
- "Fish Allergy",
- "Wheat/Gluten",
- "Dairy",
- "Eggs",
- "No Beef",
- "No Pork",
-];
-
-export function ApplicationPreview({ questions }: ApplicationPreviewProps) {
- const shortAnswersRef = useRef(null);
- const hasScrolled = useRef(false);
+function PreviewResumeCard() {
+ return (
+
+
+
Resume (Optional)
+
+ Upload a PDF up to {RESUME_PREVIEW_MAX_MB} MB.
+
+
- useEffect(() => {
- if (
- questions.length > 0 &&
- !hasScrolled.current &&
- shortAnswersRef.current
- ) {
- shortAnswersRef.current.scrollIntoView({ behavior: "smooth" });
- hasScrolled.current = true;
- }
- }, [questions]);
+
No resume uploaded.
- const sortedQuestions = [...questions].sort(
- (a, b) => a.display_order - b.display_order,
- );
+
+
+
+ Upload Resume
+
- return (
-
- {/* Step pills */}
-
- {[
- "Personal Info",
- "School",
- "Experience",
- "Short Answers",
- "Event",
- "Sponsor",
- "Agreements",
- ].map((label) => (
-
- {label}
-
- ))}
+
+
+ Delete Resume
+
+
+ );
+}
- {/* 1. Personal Info */}
-
-
-
-
-
-
-
-
- {/* 2. School Info */}
-
+function renderField(field: ApplicationSchemaField) {
+ switch (field.type) {
+ case "text":
+ case "phone":
+ case "number":
+ return (
-
-
-
-
- {/* 3. Experience */}
-
-
-
+ );
+ case "select":
+ return (
-
-
- {/* 4. Short Answers — live from props */}
-
-
- {sortedQuestions.length === 0 ? (
-
- No questions configured yet.
-
- ) : (
- sortedQuestions.map((q, i) => (
-
- ))
- )}
-
-
- {/* 5. Event Info */}
-
-
-
+ );
+ case "multi_select":
+ return (
+
- Dietary Restrictions
+ {field.label}
+ {field.required && * }
— select all that apply
- {DIETARY_OPTIONS.map((label) => (
-
+ {(field.options ?? []).map((option) => (
+
))}
-
-
+ );
+ case "checkbox":
+ return
;
+ }
+}
- {/* 6. Sponsor Info */}
-
-
-
-
-
+export function ApplicationPreview({
+ fields,
+ sections,
+}: ApplicationPreviewProps) {
+ const grouped = groupFieldsBySection(fields);
- {/* 7. Agreements */}
-
-
-
+ const previewSections = sections.filter(
+ (section) =>
+ (grouped[section.id]?.length ?? 0) > 0 || section.id === "links",
+ );
+
+ const stepPills = previewSections.map((section) => section.label);
+
+ return (
+
+ {/* Step pills */}
+
+ {stepPills.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {/* Dynamic sections from schema */}
+ {previewSections.map((section) => {
+ const sectionFields = grouped[section.id] ?? [];
+
+ return (
+
+ {sectionFields.map(renderField)}
+ {section.id === "links" && }
+
+ );
+ })}
);
}
diff --git a/client/web/src/pages/superadmin/application/components/FieldCard.tsx b/client/web/src/pages/superadmin/application/components/FieldCard.tsx
new file mode 100644
index 00000000..20e352f4
--- /dev/null
+++ b/client/web/src/pages/superadmin/application/components/FieldCard.tsx
@@ -0,0 +1,273 @@
+import { ChevronDown, ChevronUp, Settings2, Trash2 } from "lucide-react";
+import { useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import type { ApplicationSchemaField, FieldType } from "@/types";
+
+import { FIELD_TYPE_LABELS, TYPE_COLORS } from "../constants";
+import { OptionsEditor } from "./OptionsEditor";
+
+interface FieldCardProps {
+ field: ApplicationSchemaField;
+ onUpdate: (updates: Partial
) => void;
+ onRemove: () => void;
+ onMove: (direction: "up" | "down") => void;
+ isFirst: boolean;
+ isLast: boolean;
+}
+
+const FIELD_TYPES: FieldType[] = [
+ "text",
+ "number",
+ "textarea",
+ "select",
+ "multi_select",
+ "checkbox",
+ "phone",
+];
+
+export function FieldCard({
+ field,
+ onUpdate,
+ onRemove,
+ onMove,
+ isFirst,
+ isLast,
+}: FieldCardProps) {
+ const [detailsOpen, setDetailsOpen] = useState(false);
+ const hasOptions = field.type === "select" || field.type === "multi_select";
+ const hasValidation =
+ field.validation && Object.keys(field.validation).length > 0;
+
+ return (
+
+ {/* Top row: type badge, label input, reorder, delete */}
+
+
+ {FIELD_TYPE_LABELS[field.type]}
+
+
onUpdate({ label: e.target.value })}
+ placeholder="Field label..."
+ className="h-8 text-sm flex-1"
+ />
+
+ onMove("up")}
+ disabled={isFirst}
+ className="h-7 w-7 p-0 cursor-pointer"
+ >
+
+
+ onMove("down")}
+ disabled={isLast}
+ className="h-7 w-7 p-0 cursor-pointer"
+ >
+
+
+
+
+
+
+
+
+ {/* Required toggle + details expand */}
+
+
+ onUpdate({ required: checked })}
+ className="cursor-pointer"
+ />
+
+ Required
+
+
+
setDetailsOpen((prev) => !prev)}
+ className="h-7 text-xs text-muted-foreground cursor-pointer gap-1"
+ >
+
+ {detailsOpen ? "Hide" : "Details"}
+
+
+
+ {/* Expandable details */}
+
+
+ {/* Type selector */}
+
+
+ Field Type
+
+
{
+ const updates: Partial = {
+ type: value,
+ };
+ // Clear options if switching away from select types
+ if (value !== "select" && value !== "multi_select") {
+ updates.options = undefined;
+ }
+ // Add empty options array if switching to select types
+ if (
+ (value === "select" || value === "multi_select") &&
+ !field.options
+ ) {
+ updates.options = [""];
+ }
+ onUpdate(updates);
+ }}
+ >
+
+
+
+
+ {FIELD_TYPES.map((type) => (
+
+ {FIELD_TYPE_LABELS[type]}
+
+ ))}
+
+
+
+
+ {/* Options editor for select types */}
+ {hasOptions && (
+ onUpdate({ options })}
+ />
+ )}
+
+ {/* Validation fields */}
+ {field.type === "textarea" && (
+
+
+ Max Length
+
+ {
+ const val = e.target.value
+ ? parseInt(e.target.value, 10)
+ : undefined;
+ onUpdate({
+ validation: val
+ ? { ...field.validation, maxLength: val }
+ : Object.fromEntries(
+ Object.entries(field.validation ?? {}).filter(
+ ([k]) => k !== "maxLength",
+ ),
+ ),
+ });
+ }}
+ placeholder="e.g. 1000"
+ className="h-8 text-sm"
+ />
+
+ )}
+
+ {field.type === "number" && (
+
+ )}
+
+ {/* Show existing validation as read-only if type doesn't have dedicated editors */}
+ {hasValidation &&
+ field.type !== "textarea" &&
+ field.type !== "number" && (
+
+
+ Validation
+
+
+ {JSON.stringify(field.validation)}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/client/web/src/pages/superadmin/application/components/OptionsEditor.tsx b/client/web/src/pages/superadmin/application/components/OptionsEditor.tsx
new file mode 100644
index 00000000..0d97539e
--- /dev/null
+++ b/client/web/src/pages/superadmin/application/components/OptionsEditor.tsx
@@ -0,0 +1,58 @@
+import { Plus, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+interface OptionsEditorProps {
+ options: string[];
+ onChange: (options: string[]) => void;
+}
+
+export function OptionsEditor({ options, onChange }: OptionsEditorProps) {
+ const updateOption = (index: number, value: string) => {
+ onChange(options.map((o, i) => (i === index ? value : o)));
+ };
+
+ const removeOption = (index: number) => {
+ onChange(options.filter((_, i) => i !== index));
+ };
+
+ const addOption = () => {
+ onChange([...options, ""]);
+ };
+
+ return (
+
+
+ Options
+
+ {options.map((option, index) => (
+
+ updateOption(index, e.target.value)}
+ placeholder={`Option ${index + 1}`}
+ className="h-8 text-sm"
+ />
+ removeOption(index)}
+ className="h-8 w-8 p-0 shrink-0 text-muted-foreground hover:text-red-500 cursor-pointer"
+ >
+
+
+
+ ))}
+
+
+ Add Option
+
+
+ );
+}
diff --git a/client/web/src/pages/superadmin/application/components/SchemaEditor.tsx b/client/web/src/pages/superadmin/application/components/SchemaEditor.tsx
new file mode 100644
index 00000000..cf805ce2
--- /dev/null
+++ b/client/web/src/pages/superadmin/application/components/SchemaEditor.tsx
@@ -0,0 +1,166 @@
+import { ChevronDown, ChevronUp, Pencil, Trash2 } from "lucide-react";
+import { useState } from "react";
+
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+import { useApplicationSchemaStore } from "../store";
+import { AddFieldDialog } from "./AddFieldDialog";
+import { AddSectionDialog } from "./AddSectionDialog";
+import { FieldCard } from "./FieldCard";
+
+export function SchemaEditor() {
+ const fields = useApplicationSchemaStore((s) => s.fields);
+ const sections = useApplicationSchemaStore((s) => s.sections);
+ const updateField = useApplicationSchemaStore((s) => s.updateField);
+ const removeField = useApplicationSchemaStore((s) => s.removeField);
+ const moveField = useApplicationSchemaStore((s) => s.moveField);
+ const removeSection = useApplicationSchemaStore((s) => s.removeSection);
+ const renameSection = useApplicationSchemaStore((s) => s.renameSection);
+ const moveSection = useApplicationSchemaStore((s) => s.moveSection);
+
+ const [editingSectionId, setEditingSectionId] = useState(null);
+ const [editingLabel, setEditingLabel] = useState("");
+
+ const fieldsBySection = sections.map((section) => ({
+ section: section.id,
+ label: section.label,
+ fields: fields
+ .filter((f) => f.section === section.id)
+ .sort((a, b) => a.display_order - b.display_order),
+ }));
+
+ const startRename = (sectionId: string, currentLabel: string) => {
+ setEditingSectionId(sectionId);
+ setEditingLabel(currentLabel);
+ };
+
+ const commitRename = () => {
+ if (editingSectionId && editingLabel.trim()) {
+ renameSection(editingSectionId, editingLabel.trim());
+ }
+ setEditingSectionId(null);
+ setEditingLabel("");
+ };
+
+ return (
+
+
+ Configure the fields that appear on hacker applications. Fields are
+ grouped by section.
+
+
+
+ {fieldsBySection.map(
+ ({ section, label, fields: sectionFields }, sectionIdx) => (
+
+
+
+ {editingSectionId === section ? (
+ setEditingLabel(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") commitRename();
+ if (e.key === "Escape") {
+ setEditingSectionId(null);
+ setEditingLabel("");
+ }
+ }}
+ onClick={(e) => e.stopPropagation()}
+ className="h-7 text-sm font-medium w-48"
+ />
+ ) : (
+ {label}
+ )}
+
+ {sectionFields.length}
+
+
+
+
+
+ {/* Section action bar */}
+
+
startRename(section, label)}
+ >
+
+ Rename
+
+
moveSection(section, "up")}
+ >
+
+ Up
+
+
moveSection(section, "down")}
+ >
+
+ Down
+
+
removeSection(section)}
+ >
+
+ Delete Section
+
+
+
+ {sectionFields.length === 0 ? (
+
+ No fields in this section.
+
+ ) : (
+ sectionFields.map((field, idx) => (
+
updateField(field.id, updates)}
+ onRemove={() => removeField(field.id)}
+ onMove={(dir) => moveField(field.id, dir)}
+ isFirst={idx === 0}
+ isLast={idx === sectionFields.length - 1}
+ />
+ ))
+ )}
+
+
+
+
+ ),
+ )}
+
+
+
+
+ );
+}
diff --git a/client/web/src/pages/superadmin/application/constants.ts b/client/web/src/pages/superadmin/application/constants.ts
new file mode 100644
index 00000000..3d52b01b
--- /dev/null
+++ b/client/web/src/pages/superadmin/application/constants.ts
@@ -0,0 +1,21 @@
+import type { FieldType } from "@/types";
+
+export const FIELD_TYPE_LABELS: Record = {
+ text: "Text",
+ number: "Number",
+ textarea: "Long Text",
+ select: "Dropdown",
+ multi_select: "Multi Select",
+ checkbox: "Checkbox",
+ phone: "Phone",
+};
+
+export const TYPE_COLORS: Record = {
+ text: "bg-blue-50 text-blue-700 border-blue-200",
+ number: "bg-purple-50 text-purple-700 border-purple-200",
+ textarea: "bg-green-50 text-green-700 border-green-200",
+ select: "bg-amber-50 text-amber-700 border-amber-200",
+ multi_select: "bg-orange-50 text-orange-700 border-orange-200",
+ checkbox: "bg-pink-50 text-pink-700 border-pink-200",
+ phone: "bg-cyan-50 text-cyan-700 border-cyan-200",
+};
diff --git a/client/web/src/pages/superadmin/application/store.ts b/client/web/src/pages/superadmin/application/store.ts
index 4298915e..dc210f6a 100644
--- a/client/web/src/pages/superadmin/application/store.ts
+++ b/client/web/src/pages/superadmin/application/store.ts
@@ -2,92 +2,208 @@ import { toast } from "sonner";
import { create } from "zustand";
import { errorAlert } from "@/shared/lib/api";
-import type { ShortAnswerQuestion } from "@/types";
+import { deriveSections, type SectionDef } from "@/shared/lib/schema-utils";
+import type { ApplicationSchemaField } from "@/types";
-import { fetchSAQuestions, saveSAQuestions } from "./api";
+import { fetchApplicationSchema, saveApplicationSchema } from "./api";
-interface ApplicationSettingsState {
- questions: ShortAnswerQuestion[];
+interface ApplicationSchemaState {
+ fields: ApplicationSchemaField[];
+ sections: SectionDef[];
loading: boolean;
saving: boolean;
- fetchQuestions: (signal?: AbortSignal) => Promise;
- saveQuestions: () => Promise;
- updateQuestion: (
- index: number,
- field: keyof ShortAnswerQuestion,
- value: string | boolean | number,
+ fetchSchema: (signal?: AbortSignal) => Promise;
+ saveSchema: () => Promise;
+ updateField: (
+ fieldId: string,
+ updates: Partial,
) => void;
- addQuestion: () => void;
- removeQuestion: (index: number) => void;
+ addField: (field: ApplicationSchemaField) => void;
+ removeField: (fieldId: string) => void;
+ moveField: (fieldId: string, direction: "up" | "down") => void;
+ addSection: (label: string) => void;
+ removeSection: (sectionId: string) => void;
+ renameSection: (sectionId: string, label: string) => void;
+ moveSection: (sectionId: string, direction: "up" | "down") => void;
}
-export const useApplicationSettingsStore = create(
+/** Derive sections from the current field list. */
+function buildSections(fields: ApplicationSchemaField[]): SectionDef[] {
+ return deriveSections(fields);
+}
+
+/**
+ * Stamp every field with the correct section_label and section_order
+ * based on the current sections array. Also recalculates display_order.
+ */
+function stampFields(
+ fields: ApplicationSchemaField[],
+ sections: SectionDef[],
+): ApplicationSchemaField[] {
+ const sectionMeta = new Map(
+ sections.map((s, i) => [s.id, { label: s.label, order: i + 1 }]),
+ );
+ const sectionCounters: Record = {};
+
+ return fields.map((f) => {
+ const meta = sectionMeta.get(f.section);
+ sectionCounters[f.section] = (sectionCounters[f.section] ?? 0) + 1;
+ return {
+ ...f,
+ section_label: meta?.label ?? f.section,
+ section_order: meta?.order ?? 999,
+ display_order: sectionCounters[f.section],
+ };
+ });
+}
+
+export const useApplicationSchemaStore = create(
(set, get) => ({
- questions: [],
+ fields: [],
+ sections: [],
loading: false,
saving: false,
- fetchQuestions: async (signal?: AbortSignal) => {
+ fetchSchema: async (signal?: AbortSignal) => {
set({ loading: true });
- const res = await fetchSAQuestions(signal);
+ const res = await fetchApplicationSchema(signal);
if (signal?.aborted) return;
if (res.status === 200 && res.data) {
- set({ questions: res.data.questions ?? [], loading: false });
+ const fields = res.data.fields ?? [];
+ set({
+ fields,
+ sections: buildSections(fields),
+ loading: false,
+ });
} else {
errorAlert(res);
set({ loading: false });
}
},
- saveQuestions: async () => {
- const { questions } = get();
- const emptyQuestion = questions.find((q) => !q.question.trim());
- if (emptyQuestion) {
- toast.error("All questions must have text before saving");
+ saveSchema: async () => {
+ const { fields, sections } = get();
+
+ const emptyLabel = fields.find((f) => !f.label.trim());
+ if (emptyLabel) {
+ toast.error("All fields must have a label before saving");
+ return;
+ }
+
+ const missingOptions = fields.find(
+ (f) =>
+ (f.type === "select" || f.type === "multi_select") &&
+ (!f.options || f.options.length === 0),
+ );
+ if (missingOptions) {
+ toast.error(`"${missingOptions.label}" needs at least one option`);
return;
}
+ const normalized = stampFields(fields, sections);
+
set({ saving: true });
- const payload = questions.map((q, i) => ({
- ...q,
- display_order: i + 1,
- }));
- const res = await saveSAQuestions(payload);
+ const res = await saveApplicationSchema(normalized);
if (res.status === 200 && res.data) {
- toast.success("Questions saved");
+ const saved = res.data.fields;
+ set({ fields: saved, sections: buildSections(saved), saving: false });
+ toast.success("Application schema saved");
} else {
errorAlert(res);
+ set({ saving: false });
}
- set({ saving: false });
},
- updateQuestion: (index, field, value) => {
+ updateField: (fieldId, updates) => {
set((state) => ({
- questions: state.questions.map((q, i) =>
- i === index ? { ...q, [field]: value } : q,
+ fields: state.fields.map((f) =>
+ f.id === fieldId ? { ...f, ...updates } : f,
),
}));
},
- addQuestion: () => {
+ addField: (field) => {
+ set((state) => ({ fields: [...state.fields, field] }));
+ },
+
+ removeField: (fieldId) => {
+ set((state) => {
+ const newFields = state.fields.filter((f) => f.id !== fieldId);
+ return {
+ fields: newFields,
+ // Keep sections intact — empty sections are allowed during editing
+ };
+ });
+ },
+
+ moveField: (fieldId, direction) => {
+ set((state) => {
+ const field = state.fields.find((f) => f.id === fieldId);
+ if (!field) return state;
+
+ const sectionFields = state.fields
+ .filter((f) => f.section === field.section)
+ .sort((a, b) => a.display_order - b.display_order);
+
+ const idx = sectionFields.findIndex((f) => f.id === fieldId);
+ const swapIdx = direction === "up" ? idx - 1 : idx + 1;
+ if (swapIdx < 0 || swapIdx >= sectionFields.length) return state;
+
+ const swapField = sectionFields[swapIdx];
+ const tempOrder = field.display_order;
+
+ return {
+ fields: state.fields.map((f) => {
+ if (f.id === fieldId)
+ return { ...f, display_order: swapField.display_order };
+ if (f.id === swapField.id)
+ return { ...f, display_order: tempOrder };
+ return f;
+ }),
+ };
+ });
+ },
+
+ addSection: (label) => {
+ set((state) => {
+ const id = `section_${Date.now()}`;
+ const newSection: SectionDef = { id, label };
+ return { sections: [...state.sections, newSection] };
+ });
+ },
+
+ removeSection: (sectionId) => {
set((state) => ({
- questions: [
- ...state.questions,
- {
- id: `saq_${Date.now()}`,
- question: "",
- required: false,
- display_order: state.questions.length + 1,
- },
- ],
+ sections: state.sections.filter((s) => s.id !== sectionId),
+ fields: state.fields.filter((f) => f.section !== sectionId),
}));
},
- removeQuestion: (index) => {
+ renameSection: (sectionId, label) => {
set((state) => ({
- questions: state.questions.filter((_, i) => i !== index),
+ sections: state.sections.map((s) =>
+ s.id === sectionId ? { ...s, label } : s,
+ ),
+ fields: state.fields.map((f) =>
+ f.section === sectionId ? { ...f, section_label: label } : f,
+ ),
}));
},
+
+ moveSection: (sectionId, direction) => {
+ set((state) => {
+ const idx = state.sections.findIndex((s) => s.id === sectionId);
+ const swapIdx = direction === "up" ? idx - 1 : idx + 1;
+ if (swapIdx < 0 || swapIdx >= state.sections.length) return state;
+
+ const newSections = [...state.sections];
+ [newSections[idx], newSections[swapIdx]] = [
+ newSections[swapIdx],
+ newSections[idx],
+ ];
+ return { sections: newSections };
+ });
+ },
}),
);
diff --git a/client/web/src/shared/lib/schema-utils.ts b/client/web/src/shared/lib/schema-utils.ts
new file mode 100644
index 00000000..7b986cb3
--- /dev/null
+++ b/client/web/src/shared/lib/schema-utils.ts
@@ -0,0 +1,250 @@
+import { createElement, type ReactNode } from "react";
+import { z } from "zod";
+
+import type { ApplicationSchemaField } from "@/types";
+
+/** Well-known section labels for backward compatibility with data that lacks section_label. */
+const DEFAULT_SECTION_LABELS: Record = {
+ personal: "Personal Information",
+ education: "Education",
+ links: "Links & Profiles",
+ experience: "Experience",
+ short_answers: "Short Answer Questions",
+ logistics: "Event Logistics",
+ agreements: "Agreements",
+};
+
+export interface SectionDef {
+ id: string;
+ label: string;
+}
+
+/**
+ * Derive an ordered list of sections from schema fields.
+ * Uses section_order for ordering and section_label for display names,
+ * falling back to DEFAULT_SECTION_LABELS for legacy data.
+ */
+export function deriveSections(fields: ApplicationSchemaField[]): SectionDef[] {
+ const seen = new Map();
+
+ for (const f of fields) {
+ if (!seen.has(f.section)) {
+ seen.set(f.section, {
+ label:
+ f.section_label || DEFAULT_SECTION_LABELS[f.section] || f.section,
+ order: f.section_order ?? 999,
+ });
+ }
+ }
+
+ return [...seen.entries()]
+ .sort(([, a], [, b]) => a.order - b.order)
+ .map(([id, { label }]) => ({ id, label }));
+}
+
+/**
+ * Build SECTION_ORDER and SECTION_LABELS dynamically from schema fields.
+ * Convenience wrapper used by components that need both.
+ */
+export function getSectionInfo(fields: ApplicationSchemaField[]) {
+ const sections = deriveSections(fields);
+ const order = sections.map((s) => s.id);
+ const labels: Record = {};
+ for (const s of sections) {
+ labels[s.id] = s.label;
+ }
+ return { order, labels };
+}
+
+/** Group schema fields by section, sorted by display_order within each section. */
+export function groupFieldsBySection(
+ schema: ApplicationSchemaField[],
+): Record {
+ const groups: Record = {};
+
+ // Initialize groups for all sections present in the schema
+ for (const field of schema) {
+ if (!groups[field.section]) {
+ groups[field.section] = [];
+ }
+ groups[field.section].push(field);
+ }
+
+ // Sort fields within each section by display_order
+ for (const section of Object.keys(groups)) {
+ groups[section].sort((a, b) => a.display_order - b.display_order);
+ }
+
+ return groups;
+}
+
+/** Type-safe accessor for a response value. */
+export function getResponseValue(
+ responses: Record | undefined | null,
+ fieldId: string,
+ fallback: T,
+): T {
+ if (!responses) return fallback;
+ const val = responses[fieldId];
+ if (val === undefined || val === null) return fallback;
+ return val as T;
+}
+
+/** Build a Zod schema for a single field based on its ApplicationSchemaField definition. */
+function buildFieldZod(field: ApplicationSchemaField): z.ZodType {
+ const validation = field.validation ?? {};
+
+ switch (field.type) {
+ case "text": {
+ if (field.required) {
+ return z.string().min(1, `${field.label} is required`);
+ }
+ return z.string().optional().default("");
+ }
+ case "phone": {
+ if (field.required) {
+ return z
+ .string()
+ .min(1, `${field.label} is required`)
+ .regex(
+ /^\+[1-9]\d{1,14}$/,
+ "Phone must be in E.164 format (e.g., +12025551234)",
+ );
+ }
+ return z.string().optional().default("");
+ }
+ case "number": {
+ let n = z.coerce.number({ message: `${field.label} is required` });
+ if (typeof validation.min === "number")
+ n = n.min(validation.min as number);
+ if (typeof validation.max === "number")
+ n = n.max(validation.max as number);
+ if (field.required && typeof validation.min !== "number") n = n.min(0);
+ return n;
+ }
+ case "textarea": {
+ let s = z.string();
+ if (field.required) s = s.min(1, `${field.label} is required`);
+ if (typeof validation.maxLength === "number")
+ s = s.max(validation.maxLength as number);
+ return s;
+ }
+ case "select": {
+ if (field.required) {
+ return z.string().min(1, `${field.label} is required`);
+ }
+ return z.string().optional().default("");
+ }
+ case "multi_select":
+ return z.array(z.string()).optional().default([]);
+ case "checkbox":
+ if (field.required) {
+ return z.literal(true, {
+ message: `${stripLabelLinks(field.label)} is required`,
+ });
+ }
+ return z.boolean().optional().default(false);
+ default:
+ return z.string().optional().default("");
+ }
+}
+
+/**
+ * Build a Zod object schema from an array of ApplicationSchemaField definitions.
+ * Returns a z.object() with one key per field.
+ */
+export function buildZodSchema(fields: ApplicationSchemaField[]) {
+ const shape: Record = {};
+ for (const field of fields) {
+ shape[field.id] = buildFieldZod(field);
+ }
+ return z.object(shape);
+}
+
+/** Build default form values from schema fields. */
+export function buildDefaultValues(
+ fields: ApplicationSchemaField[],
+): Record {
+ const defaults: Record = {};
+ for (const field of fields) {
+ switch (field.type) {
+ case "number":
+ defaults[field.id] = 0;
+ break;
+ case "multi_select":
+ defaults[field.id] = [];
+ break;
+ case "checkbox":
+ defaults[field.id] = false;
+ break;
+ default:
+ defaults[field.id] = "";
+ }
+ }
+ return defaults;
+}
+
+/** Format a response value for display. */
+export function formatResponseValue(
+ value: unknown,
+ field: ApplicationSchemaField,
+): string {
+ if (value === null || value === undefined || value === "")
+ return "Not provided";
+
+ if (field.type === "multi_select" && Array.isArray(value)) {
+ return value.length > 0 ? value.join(", ") : "None";
+ }
+ if (field.type === "checkbox") {
+ return value ? "Yes" : "No";
+ }
+ if (field.type === "number") {
+ return String(value);
+ }
+ return String(value);
+}
+
+const LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
+
+/** Strip markdown-style links from a label, keeping only the text. */
+export function stripLabelLinks(label: string): string {
+ return label.replace(LINK_RE, "$1");
+}
+
+/** Parse markdown-style [text](url) links in a label and return React nodes. */
+export function renderLabel(label: string): ReactNode {
+ if (!label.includes("[")) return label;
+
+ const parts: ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ let key = 0;
+
+ const re = new RegExp(LINK_RE.source, LINK_RE.flags);
+ while ((match = re.exec(label)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push(label.slice(lastIndex, match.index));
+ }
+ parts.push(
+ createElement(
+ "a",
+ {
+ key: key++,
+ href: match[2],
+ target: "_blank",
+ rel: "noopener noreferrer",
+ className:
+ "underline underline-offset-4 text-blue-600 hover:text-blue-800",
+ },
+ match[1],
+ ),
+ );
+ lastIndex = re.lastIndex;
+ }
+
+ if (lastIndex < label.length) {
+ parts.push(label.slice(lastIndex));
+ }
+
+ return parts.length > 0 ? parts : label;
+}
diff --git a/client/web/src/types.ts b/client/web/src/types.ts
index 973ce74b..bf46dd68 100644
--- a/client/web/src/types.ts
+++ b/client/web/src/types.ts
@@ -1,10 +1,25 @@
export type UserRole = "hacker" | "admin" | "super_admin";
-export interface ShortAnswerQuestion {
+export type FieldType =
+ | "text"
+ | "number"
+ | "textarea"
+ | "select"
+ | "multi_select"
+ | "checkbox"
+ | "phone";
+
+export interface ApplicationSchemaField {
id: string;
- question: string;
+ type: FieldType;
+ label: string;
required: boolean;
+ section: string;
+ section_label?: string;
+ section_order?: number;
display_order: number;
+ options?: string[];
+ validation?: Record;
}
export type ApplicationStatus =
@@ -33,7 +48,7 @@ export interface Review {
university: string | null;
major: string | null;
country_of_residence: string | null;
- hackathons_attended_count: number | null;
+ hackathons_attended: number | null;
}
export interface PendingReviewsResponse {
@@ -64,37 +79,18 @@ export interface Application {
id: string;
user_id: string;
status: ApplicationStatus;
- first_name: string | null;
- last_name: string | null;
- phone_e164: string | null;
- age: number | null;
- country_of_residence: string | null;
- gender: string | null;
- race: string | null;
- ethnicity: string | null;
- university: string | null;
- major: string | null;
- level_of_study: string | null;
- short_answer_responses: Record | null;
- short_answer_questions: ShortAnswerQuestion[];
- hackathons_attended_count: number | null;
- software_experience_level: string | null;
- heard_about: string | null;
- shirt_size: string | null;
- dietary_restrictions: string[];
- accommodations: string | null;
- github: string | null;
- linkedin: string | null;
- website: string | null;
+ responses: Record;
+ application_schema: ApplicationSchemaField[];
resume_path: string | null;
- ack_application: boolean;
- ack_mlh_coc: boolean;
- ack_mlh_privacy: boolean;
- opt_in_mlh_emails: boolean;
+ ai_percent: number | null;
+ accept_votes: number;
+ reject_votes: number;
+ waitlist_votes: number;
+ reviews_assigned: number;
+ reviews_completed: number;
submitted_at: string | null;
created_at: string;
updated_at: string;
- ai_percent: number | null;
}
export interface ApiResponse {
@@ -119,17 +115,24 @@ export interface ApplicationListItem {
status: ApplicationStatus;
first_name: string | null;
last_name: string | null;
- phone_e164: string | null;
+ phone: string | null;
age: number | null;
country_of_residence: string | null;
gender: string | null;
university: string | null;
major: string | null;
level_of_study: string | null;
- hackathons_attended_count: number | null;
+ hackathons_attended: number | null;
submitted_at: string | null;
created_at: string;
updated_at: string;
+ accept_votes: number;
+ reject_votes: number;
+ waitlist_votes: number;
+ reviews_assigned: number;
+ reviews_completed: number;
+ ai_percent: number | null;
+ has_resume: boolean;
}
// Paginated response from admin applications endpoint
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/applications.go b/cmd/api/applications.go
index a31035b4..56f82824 100644
--- a/cmd/api/applications.go
+++ b/cmd/api/applications.go
@@ -13,45 +13,14 @@ 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"`
}
-// 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 +30,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 +66,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,90 +126,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
- }
- if req.AckMLHPrivacy != nil {
- application.AckMLHPrivacy = *req.AckMLHPrivacy
- }
- if req.OptInMLHEmails != nil {
- application.OptInMLHEmails = *req.OptInMLHEmails
- }
if err := app.store.Application.Update(r.Context(), application); err != nil {
app.internalServerError(w, r, err)
@@ -256,7 +147,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,94 +179,28 @@ 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)
- }
-
- for _, q := range questions {
- if q.Required {
- answer, exists := responses[q.ID]
- if !exists || strings.TrimSpace(answer) == "" {
- missing = append(missing, "short_answer:"+q.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")
+ responses = make(map[string]interface{})
}
- // Validate acknowledgments
- if !application.AckApplication {
- missing = append(missing, "ack_application")
- }
- if !application.AckMLHCOC {
- missing = append(missing, "ack_mlh_coc")
- }
- if !application.AckMLHPrivacy {
- missing = append(missing, "ack_mlh_privacy")
- }
+ // Validate responses against schema
+ validationErrors := validateResponses(schema, responses)
- 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
}
@@ -390,6 +215,121 @@ 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":
+ b, ok := val.(bool)
+ if !ok {
+ errs = append(errs, field.ID+" must be a boolean")
+ } else if field.Required && !b {
+ errs = append(errs, field.ID+" must be checked")
+ }
+ }
+ }
+
+ return errs
+}
+
+// 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
+ }
+}
+
+// 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)
@@ -588,14 +528,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 +559,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 +628,4 @@ func (app *application) getApplicantEmailsByStatusHandler(w http.ResponseWriter,
if err = app.jsonResponse(w, http.StatusOK, response); err != nil {
app.internalServerError(w, r, err)
}
-
}
diff --git a/cmd/api/applications_test.go b/cmd/api/applications_test.go
index e4e67d2b..8e7980e7 100644
--- a/cmd/api/applications_test.go
+++ b/cmd/api/applications_test.go
@@ -17,47 +17,21 @@ 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":"+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":2,
+ "experience_level":"Intermediate","heard_about":"Friend",
+ "shirt_size":"M",
+ "ack_mlh_coc":true,"ack_mlh_privacy":true
+ }`),
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
}
}
@@ -69,10 +43,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 +61,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 +81,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 +104,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 +129,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 +146,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 +157,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 +167,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 +189,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)
@@ -252,23 +210,24 @@ 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)
})
- 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 +241,70 @@ 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 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)
@@ -579,10 +601,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.go b/cmd/api/settings.go
index b4f9648a..053a5ecc 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,20 @@ 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(req)
if err := app.jsonResponse(w, http.StatusOK, response); err != nil {
app.internalServerError(w, r, err)
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)
})
}
diff --git a/cmd/migrate/migrations/000001_create_users.down.sql b/cmd/migrate/migrations/000001_create_extensions.down.sql
similarity index 53%
rename from cmd/migrate/migrations/000001_create_users.down.sql
rename to cmd/migrate/migrations/000001_create_extensions.down.sql
index 851f4d55..2b949afc 100644
--- a/cmd/migrate/migrations/000001_create_users.down.sql
+++ b/cmd/migrate/migrations/000001_create_extensions.down.sql
@@ -1,5 +1,3 @@
-DROP TABLE IF EXISTS users;
-DROP TYPE IF EXISTS user_role;
-
-DROP EXTENSION IF EXISTS citext;
+DROP EXTENSION IF EXISTS pg_trgm;
DROP EXTENSION IF EXISTS pgcrypto;
+DROP EXTENSION IF EXISTS citext;
diff --git a/cmd/migrate/migrations/000001_create_extensions.up.sql b/cmd/migrate/migrations/000001_create_extensions.up.sql
new file mode 100644
index 00000000..0ae806d0
--- /dev/null
+++ b/cmd/migrate/migrations/000001_create_extensions.up.sql
@@ -0,0 +1,3 @@
+CREATE EXTENSION IF NOT EXISTS citext;
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
diff --git a/cmd/migrate/migrations/000001_create_users.up.sql b/cmd/migrate/migrations/000001_create_users.up.sql
deleted file mode 100644
index 2b865c66..00000000
--- a/cmd/migrate/migrations/000001_create_users.up.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-CREATE EXTENSION IF NOT EXISTS citext;
-CREATE EXTENSION IF NOT EXISTS pgcrypto;
-
-DO $$ BEGIN
- CREATE TYPE user_role AS ENUM ('hacker', 'admin', 'super_admin');
-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',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
diff --git a/cmd/migrate/migrations/000002_create_utility_functions.down.sql b/cmd/migrate/migrations/000002_create_utility_functions.down.sql
new file mode 100644
index 00000000..98c233db
--- /dev/null
+++ b/cmd/migrate/migrations/000002_create_utility_functions.down.sql
@@ -0,0 +1 @@
+DROP FUNCTION IF EXISTS set_updated_at();
diff --git a/cmd/migrate/migrations/000002_create_utility_functions.up.sql b/cmd/migrate/migrations/000002_create_utility_functions.up.sql
new file mode 100644
index 00000000..002005c4
--- /dev/null
+++ b/cmd/migrate/migrations/000002_create_utility_functions.up.sql
@@ -0,0 +1,7 @@
+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/migrations/000002_updated_at_trigger.down.sql b/cmd/migrate/migrations/000002_updated_at_trigger.down.sql
deleted file mode 100644
index 53bec42a..00000000
--- a/cmd/migrate/migrations/000002_updated_at_trigger.down.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
-
-DROP FUNCTION IF EXISTS set_updated_at();
diff --git a/cmd/migrate/migrations/000002_updated_at_trigger.up.sql b/cmd/migrate/migrations/000002_updated_at_trigger.up.sql
deleted file mode 100644
index 7f68bc61..00000000
--- a/cmd/migrate/migrations/000002_updated_at_trigger.up.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-CREATE OR REPLACE FUNCTION set_updated_at()
-RETURNS TRIGGER AS $$
-BEGIN
- NEW.updated_at = now();
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
--- add to users
-DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
-
-CREATE TRIGGER trg_users_updated_at
-BEFORE UPDATE ON users
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
diff --git a/cmd/migrate/migrations/000003_add_auth_method.down.sql b/cmd/migrate/migrations/000003_add_auth_method.down.sql
deleted file mode 100644
index 102445ed..00000000
--- a/cmd/migrate/migrations/000003_add_auth_method.down.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE users DROP COLUMN IF EXISTS auth_method;
-DROP TYPE IF EXISTS auth_method;
diff --git a/cmd/migrate/migrations/000003_add_auth_method.up.sql b/cmd/migrate/migrations/000003_add_auth_method.up.sql
deleted file mode 100644
index cbc0b442..00000000
--- a/cmd/migrate/migrations/000003_add_auth_method.up.sql
+++ /dev/null
@@ -1,7 +0,0 @@
-DO $$ BEGIN
- CREATE TYPE auth_method AS ENUM ('passwordless', 'google');
-EXCEPTION
- WHEN duplicate_object THEN NULL;
-END $$;
-
-ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_method auth_method NOT NULL DEFAULT 'passwordless';
diff --git a/cmd/migrate/migrations/000003_create_user_types.down.sql b/cmd/migrate/migrations/000003_create_user_types.down.sql
new file mode 100644
index 00000000..ca74af76
--- /dev/null
+++ b/cmd/migrate/migrations/000003_create_user_types.down.sql
@@ -0,0 +1,2 @@
+DROP TYPE IF EXISTS auth_method;
+DROP TYPE IF EXISTS user_role;
diff --git a/cmd/migrate/migrations/000003_create_user_types.up.sql b/cmd/migrate/migrations/000003_create_user_types.up.sql
new file mode 100644
index 00000000..2c24e86a
--- /dev/null
+++ b/cmd/migrate/migrations/000003_create_user_types.up.sql
@@ -0,0 +1,11 @@
+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 $$;
diff --git a/cmd/migrate/migrations/000004_create_applications.down.sql b/cmd/migrate/migrations/000004_create_applications.down.sql
deleted file mode 100644
index 08ab8579..00000000
--- a/cmd/migrate/migrations/000004_create_applications.down.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-DROP TRIGGER IF EXISTS trg_applications_updated_at ON applications;
-
-DROP TABLE IF EXISTS applications;
-
-DO $$ BEGIN
- DROP TYPE IF EXISTS dietary_restriction;
-EXCEPTION
- WHEN undefined_object THEN NULL;
-END $$;
-
-DO $$ BEGIN
- DROP TYPE IF EXISTS application_status;
-EXCEPTION
- WHEN undefined_object THEN NULL;
-END $$;
\ No newline at end of file
diff --git a/cmd/migrate/migrations/000004_create_applications.up.sql b/cmd/migrate/migrations/000004_create_applications.up.sql
deleted file mode 100644
index b44abd29..00000000
--- a/cmd/migrate/migrations/000004_create_applications.up.sql
+++ /dev/null
@@ -1,82 +0,0 @@
-DO $$ BEGIN
- CREATE TYPE application_status AS ENUM ('draft', 'submitted', 'accepted', 'rejected', 'waitlisted');
-EXCEPTION
- WHEN duplicate_object THEN NULL;
-END $$;
-
-DO $$ BEGIN
- CREATE TYPE dietary_restriction AS ENUM (
- 'vegan',
- 'vegetarian',
- 'halal',
- 'nuts',
- 'fish',
- 'wheat',
- 'dairy',
- 'eggs',
- 'no_beef',
- 'no_pork'
- );
-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',
-
- first_name VARCHAR(255),
- last_name VARCHAR(255),
- phone_e164 TEXT,
- age SMALLINT,
-
- country_of_residence VARCHAR(255),
- gender VARCHAR(255),
- race VARCHAR(255),
- ethnicity VARCHAR(255),
-
- university VARCHAR(255),
- major VARCHAR(255),
- level_of_study VARCHAR(255),
-
- short_answer_responses JSONB NOT NULL DEFAULT '{}',
-
- github VARCHAR(255),
- linkedin VARCHAR(255),
- website VARCHAR(255),
-
- hackathons_attended_count SMALLINT,
- software_experience_level VARCHAR(255),
- heard_about VARCHAR(255),
-
- shirt_size VARCHAR(255),
- dietary_restrictions dietary_restriction[] NOT NULL DEFAULT '{}'::dietary_restriction[],
- accommodations TEXT,
-
- ack_application BOOLEAN NOT NULL DEFAULT FALSE,
- 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,
-
- submitted_at TIMESTAMPTZ,
-
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-
- CONSTRAINT applications_age_check CHECK (age IS NULL OR (age >= 0 AND age <= 120)),
- CONSTRAINT applications_phone_check CHECK (phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9]\d{1,14}$'),
- CONSTRAINT applications_submitted_check CHECK (
- status <> 'submitted'
- OR (submitted_at IS NOT NULL AND ack_application AND ack_mlh_coc AND ack_mlh_privacy)
- )
-);
-
-DROP TRIGGER IF EXISTS trg_applications_updated_at ON applications;
-CREATE TRIGGER trg_applications_updated_at
-BEFORE UPDATE ON applications
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
-CREATE INDEX IF NOT EXISTS idx_applications_status ON applications (status);
-CREATE INDEX IF NOT EXISTS idx_applications_submitted_at ON applications (submitted_at DESC);
diff --git a/cmd/migrate/migrations/000004_create_users.down.sql b/cmd/migrate/migrations/000004_create_users.down.sql
new file mode 100644
index 00000000..0b03a076
--- /dev/null
+++ b/cmd/migrate/migrations/000004_create_users.down.sql
@@ -0,0 +1,3 @@
+DROP INDEX IF EXISTS idx_users_email_trgm;
+DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
+DROP TABLE IF EXISTS users;
diff --git a/cmd/migrate/migrations/000004_create_users.up.sql b/cmd/migrate/migrations/000004_create_users.up.sql
new file mode 100644
index 00000000..9cc444ec
--- /dev/null
+++ b/cmd/migrate/migrations/000004_create_users.up.sql
@@ -0,0 +1,16 @@
+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/migrations/000005_add_applications_pagination_index.down.sql b/cmd/migrate/migrations/000005_add_applications_pagination_index.down.sql
deleted file mode 100644
index 685ccc6f..00000000
--- a/cmd/migrate/migrations/000005_add_applications_pagination_index.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DROP INDEX IF EXISTS idx_applications_created_at_id;
diff --git a/cmd/migrate/migrations/000005_add_applications_pagination_index.up.sql b/cmd/migrate/migrations/000005_add_applications_pagination_index.up.sql
deleted file mode 100644
index aa6faf5b..00000000
--- a/cmd/migrate/migrations/000005_add_applications_pagination_index.up.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-CREATE INDEX IF NOT EXISTS idx_applications_created_at_id
-ON applications (created_at DESC, id DESC);
diff --git a/cmd/migrate/migrations/000007_add_dynamic_questions.down.sql b/cmd/migrate/migrations/000005_create_settings.down.sql
similarity index 65%
rename from cmd/migrate/migrations/000007_add_dynamic_questions.down.sql
rename to cmd/migrate/migrations/000005_create_settings.down.sql
index 63db09a6..bcac194a 100644
--- a/cmd/migrate/migrations/000007_add_dynamic_questions.down.sql
+++ b/cmd/migrate/migrations/000005_create_settings.down.sql
@@ -1 +1,2 @@
DROP TRIGGER IF EXISTS trg_settings_updated_at ON settings;
+DROP TABLE IF EXISTS settings;
diff --git a/cmd/migrate/migrations/000006_create_settings.up.sql b/cmd/migrate/migrations/000005_create_settings.up.sql
similarity index 70%
rename from cmd/migrate/migrations/000006_create_settings.up.sql
rename to cmd/migrate/migrations/000005_create_settings.up.sql
index 0872abbf..1ab32318 100644
--- a/cmd/migrate/migrations/000006_create_settings.up.sql
+++ b/cmd/migrate/migrations/000005_create_settings.up.sql
@@ -5,3 +5,7 @@ CREATE TABLE IF NOT EXISTS settings (
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/migrations/000006_create_settings.down.sql b/cmd/migrate/migrations/000006_create_settings.down.sql
deleted file mode 100644
index f1089f3f..00000000
--- a/cmd/migrate/migrations/000006_create_settings.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DROP TABLE IF EXISTS settings;
\ No newline at end of file
diff --git a/cmd/migrate/migrations/000006_seed_settings.down.sql b/cmd/migrate/migrations/000006_seed_settings.down.sql
new file mode 100644
index 00000000..8b25743a
--- /dev/null
+++ b/cmd/migrate/migrations/000006_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/migrations/000006_seed_settings.up.sql b/cmd/migrate/migrations/000006_seed_settings.up.sql
new file mode 100644
index 00000000..f35b249e
--- /dev/null
+++ b/cmd/migrate/migrations/000006_seed_settings.up.sql
@@ -0,0 +1,67 @@
+-- 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},
+
+ {"id": "ack_mlh_coc", "type": "checkbox", "label": "I have read and agree to the [MLH Code of Conduct](https://mlh.io/code-of-conduct)", "required": true, "section": "agreements", "display_order": 60},
+ {"id": "ack_mlh_privacy", "type": "checkbox", "label": "I authorize sharing my application/registration information with Major League Hacking for event administration, ranking, and MLH administration in-line with the [MLH Privacy Policy](https://mlh.io/privacy). I further agree to the terms of both the [MLH Contest Terms and Conditions](https://github.com/MLH/mlh-policies/blob/main/contest-terms.md) and the [MLH Privacy Policy](https://mlh.io/privacy)", "required": true, "section": "agreements", "display_order": 61},
+ {"id": "opt_in_mlh_emails", "type": "checkbox", "label": "I authorize MLH to send me occasional emails about relevant events, career opportunities, and community announcements", "required": false, "section": "agreements", "display_order": 62}
+]'::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/migrations/000007_add_application_types.down.sql b/cmd/migrate/migrations/000007_add_application_types.down.sql
new file mode 100644
index 00000000..305a7a73
--- /dev/null
+++ b/cmd/migrate/migrations/000007_add_application_types.down.sql
@@ -0,0 +1 @@
+DROP TYPE IF EXISTS application_status;
diff --git a/cmd/migrate/migrations/000007_add_application_types.up.sql b/cmd/migrate/migrations/000007_add_application_types.up.sql
new file mode 100644
index 00000000..9c5f73fd
--- /dev/null
+++ b/cmd/migrate/migrations/000007_add_application_types.up.sql
@@ -0,0 +1,5 @@
+DO $$ BEGIN
+ CREATE TYPE application_status AS ENUM ('draft', 'submitted', 'accepted', 'rejected', 'waitlisted');
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;
diff --git a/cmd/migrate/migrations/000007_add_dynamic_questions.up.sql b/cmd/migrate/migrations/000007_add_dynamic_questions.up.sql
deleted file mode 100644
index 88f4a2a2..00000000
--- a/cmd/migrate/migrations/000007_add_dynamic_questions.up.sql
+++ /dev/null
@@ -1,11 +0,0 @@
-CREATE TRIGGER trg_settings_updated_at
-BEFORE UPDATE ON settings
-FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-
--- default short answer questions
-INSERT INTO settings (key, value) VALUES ('short_answer_questions', '[
- {"id": "saq_1", "question": "Why do you want to attend this hackathon?", "required": true, "display_order": 1},
- {"id": "saq_2", "question": "How many hackathons have you submitted to and what did you learn from them?", "required": true, "display_order": 2},
- {"id": "saq_3", "question": "If you haven''t been to a hackathon, what do you hope to learn from this hackathon?", "required": true, "display_order": 3},
- {"id": "saq_4", "question": "What are you looking forward to do at this hackathon?", "required": true, "display_order": 4}
-]'::jsonb);
diff --git a/cmd/migrate/migrations/000008_add_applications.down.sql b/cmd/migrate/migrations/000008_add_applications.down.sql
new file mode 100644
index 00000000..17b6158c
--- /dev/null
+++ b/cmd/migrate/migrations/000008_add_applications.down.sql
@@ -0,0 +1,8 @@
+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;
diff --git a/cmd/migrate/migrations/000008_add_applications.up.sql b/cmd/migrate/migrations/000008_add_applications.up.sql
new file mode 100644
index 00000000..55315a48
--- /dev/null
+++ b/cmd/migrate/migrations/000008_add_applications.up.sql
@@ -0,0 +1,43 @@
+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,
+
+ -- 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
+ )
+);
+
+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/migrations/000008_add_profile_picture_url.down.sql b/cmd/migrate/migrations/000008_add_profile_picture_url.down.sql
deleted file mode 100644
index 3834ce23..00000000
--- a/cmd/migrate/migrations/000008_add_profile_picture_url.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE users DROP COLUMN IF EXISTS profile_picture_url;
diff --git a/cmd/migrate/migrations/000008_add_profile_picture_url.up.sql b/cmd/migrate/migrations/000008_add_profile_picture_url.up.sql
deleted file mode 100644
index 4cce8394..00000000
--- a/cmd/migrate/migrations/000008_add_profile_picture_url.up.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_picture_url TEXT;
diff --git a/cmd/migrate/migrations/000009_add_review_types.down.sql b/cmd/migrate/migrations/000009_add_review_types.down.sql
new file mode 100644
index 00000000..49beba4a
--- /dev/null
+++ b/cmd/migrate/migrations/000009_add_review_types.down.sql
@@ -0,0 +1 @@
+DROP TYPE IF EXISTS review_vote;
diff --git a/cmd/migrate/migrations/000009_add_review_types.up.sql b/cmd/migrate/migrations/000009_add_review_types.up.sql
new file mode 100644
index 00000000..399cfae4
--- /dev/null
+++ b/cmd/migrate/migrations/000009_add_review_types.up.sql
@@ -0,0 +1,5 @@
+DO $$ BEGIN
+ CREATE TYPE review_vote AS ENUM ('accept', 'reject', 'waitlist');
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;
diff --git a/cmd/migrate/migrations/000009_add_reviews_per_application_setting.down.sql b/cmd/migrate/migrations/000009_add_reviews_per_application_setting.down.sql
deleted file mode 100644
index 887a86a9..00000000
--- a/cmd/migrate/migrations/000009_add_reviews_per_application_setting.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'reviews_per_application';
diff --git a/cmd/migrate/migrations/000009_add_reviews_per_application_setting.up.sql b/cmd/migrate/migrations/000009_add_reviews_per_application_setting.up.sql
deleted file mode 100644
index e77bb0d9..00000000
--- a/cmd/migrate/migrations/000009_add_reviews_per_application_setting.up.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-INSERT INTO settings (key, value) VALUES ('reviews_per_application', '3'::jsonb)
-ON CONFLICT (key) DO NOTHING;
diff --git a/cmd/migrate/migrations/000010_create_application_reviews.down.sql b/cmd/migrate/migrations/000010_add_application_reviews.down.sql
similarity index 74%
rename from cmd/migrate/migrations/000010_create_application_reviews.down.sql
rename to cmd/migrate/migrations/000010_add_application_reviews.down.sql
index 635c1b2a..7609f6c1 100644
--- a/cmd/migrate/migrations/000010_create_application_reviews.down.sql
+++ b/cmd/migrate/migrations/000010_add_application_reviews.down.sql
@@ -4,11 +4,4 @@ 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;
-
-DO $$ BEGIN
- DROP TYPE IF EXISTS review_vote;
-EXCEPTION
- WHEN undefined_object THEN NULL;
-END $$;
diff --git a/cmd/migrate/migrations/000010_add_application_reviews.up.sql b/cmd/migrate/migrations/000010_add_application_reviews.up.sql
new file mode 100644
index 00000000..4f3db249
--- /dev/null
+++ b/cmd/migrate/migrations/000010_add_application_reviews.up.sql
@@ -0,0 +1,27 @@
+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/migrations/000010_create_application_reviews.up.sql b/cmd/migrate/migrations/000010_create_application_reviews.up.sql
deleted file mode 100644
index 37ca5ac3..00000000
--- a/cmd/migrate/migrations/000010_create_application_reviews.up.sql
+++ /dev/null
@@ -1,49 +0,0 @@
-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(),
-
- -- Each admin can only be assigned once per application
- UNIQUE(application_id, admin_id),
-
- -- Check vote and reviewed at are both set
- 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)
- )
-);
-
-DROP TRIGGER IF EXISTS trg_application_reviews_updated_at ON application_reviews;
-CREATE TRIGGER trg_application_reviews_updated_at
-BEFORE UPDATE ON application_reviews
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
--- Performance indexes
-CREATE INDEX IF NOT EXISTS idx_reviews_application_id
- ON application_reviews(application_id);
-
-CREATE INDEX IF NOT EXISTS idx_reviews_admin_id
- ON application_reviews(admin_id);
-
--- Fast lookup for admin pending reviews
-CREATE INDEX IF NOT EXISTS idx_reviews_admin_pending
- ON application_reviews(admin_id)
- WHERE vote IS NULL;
-
--- Fast count of completed reviews per application
-CREATE INDEX IF NOT EXISTS idx_reviews_app_completed
- ON application_reviews(application_id)
- WHERE vote IS NOT NULL;
diff --git a/cmd/migrate/migrations/000011_add_application_vote_counts.down.sql b/cmd/migrate/migrations/000011_add_application_vote_counts.down.sql
deleted file mode 100644
index eaa6f011..00000000
--- a/cmd/migrate/migrations/000011_add_application_vote_counts.down.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-DROP INDEX IF EXISTS idx_applications_reviews_completed;
-
-ALTER TABLE applications
- DROP COLUMN IF EXISTS accept_votes,
- DROP COLUMN IF EXISTS reject_votes,
- DROP COLUMN IF EXISTS waitlist_votes,
- DROP COLUMN IF EXISTS reviews_assigned,
- DROP COLUMN IF EXISTS reviews_completed;
diff --git a/cmd/migrate/migrations/000011_add_application_vote_counts.up.sql b/cmd/migrate/migrations/000011_add_application_vote_counts.up.sql
deleted file mode 100644
index 302c17f2..00000000
--- a/cmd/migrate/migrations/000011_add_application_vote_counts.up.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-ALTER TABLE applications
- ADD COLUMN IF NOT EXISTS accept_votes INT NOT NULL DEFAULT 0,
- ADD COLUMN IF NOT EXISTS reject_votes INT NOT NULL DEFAULT 0,
- ADD COLUMN IF NOT EXISTS waitlist_votes INT NOT NULL DEFAULT 0,
- ADD COLUMN IF NOT EXISTS reviews_assigned INT NOT NULL DEFAULT 0,
- ADD COLUMN IF NOT EXISTS reviews_completed INT NOT NULL DEFAULT 0;
-
--- Index for filtering applications by review status
-CREATE INDEX IF NOT EXISTS idx_applications_reviews_completed
- ON applications(reviews_completed);
diff --git a/cmd/migrate/migrations/000012_add_vote_count_trigger.down.sql b/cmd/migrate/migrations/000011_add_vote_count_trigger.down.sql
similarity index 99%
rename from cmd/migrate/migrations/000012_add_vote_count_trigger.down.sql
rename to cmd/migrate/migrations/000011_add_vote_count_trigger.down.sql
index 3e83c129..845cc7ad 100644
--- a/cmd/migrate/migrations/000012_add_vote_count_trigger.down.sql
+++ b/cmd/migrate/migrations/000011_add_vote_count_trigger.down.sql
@@ -1,3 +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/migrations/000012_add_vote_count_trigger.up.sql b/cmd/migrate/migrations/000011_add_vote_count_trigger.up.sql
similarity index 89%
rename from cmd/migrate/migrations/000012_add_vote_count_trigger.up.sql
rename to cmd/migrate/migrations/000011_add_vote_count_trigger.up.sql
index 21f8fb5d..ef6efdf2 100644
--- a/cmd/migrate/migrations/000012_add_vote_count_trigger.up.sql
+++ b/cmd/migrate/migrations/000011_add_vote_count_trigger.up.sql
@@ -1,15 +1,12 @@
--- Function to update application vote counts when a review is inserted/updated/deleted
CREATE OR REPLACE FUNCTION update_application_vote_counts()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
- -- New assignment
UPDATE applications
SET reviews_assigned = reviews_assigned + 1,
updated_at = now()
WHERE id = NEW.application_id;
- -- If vote is already set on insert
IF NEW.vote IS NOT NULL THEN
UPDATE applications
SET reviews_completed = reviews_completed + 1,
@@ -22,7 +19,6 @@ BEGIN
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
- -- First vote
IF OLD.vote IS NULL AND NEW.vote IS NOT NULL THEN
UPDATE applications
SET reviews_completed = reviews_completed + 1,
@@ -31,7 +27,6 @@ BEGIN
waitlist_votes = waitlist_votes + CASE WHEN NEW.vote = 'waitlist' THEN 1 ELSE 0 END,
updated_at = now()
WHERE id = NEW.application_id;
- -- Vote += 1
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
@@ -45,7 +40,6 @@ BEGIN
+ CASE WHEN NEW.vote = 'waitlist' THEN 1 ELSE 0 END,
updated_at = now()
WHERE id = NEW.application_id;
- -- Vote removed
ELSIF OLD.vote IS NOT NULL AND NEW.vote IS NULL THEN
UPDATE applications
SET reviews_completed = reviews_completed - 1,
@@ -74,9 +68,6 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
--- Trigger to maintain denormalized counts
-DROP TRIGGER IF EXISTS trg_update_vote_counts ON application_reviews;
CREATE TRIGGER trg_update_vote_counts
AFTER INSERT OR UPDATE OR DELETE ON application_reviews
-FOR EACH ROW
-EXECUTE FUNCTION update_application_vote_counts();
+FOR EACH ROW EXECUTE FUNCTION update_application_vote_counts();
diff --git a/cmd/migrate/migrations/000013_create_scans.down.sql b/cmd/migrate/migrations/000012_add_scans.down.sql
similarity index 98%
rename from cmd/migrate/migrations/000013_create_scans.down.sql
rename to cmd/migrate/migrations/000012_add_scans.down.sql
index a6dfe8f3..5e69c8af 100644
--- a/cmd/migrate/migrations/000013_create_scans.down.sql
+++ b/cmd/migrate/migrations/000012_add_scans.down.sql
@@ -1,3 +1,2 @@
DROP INDEX IF EXISTS idx_scans_scan_type;
-
DROP TABLE IF EXISTS scans;
diff --git a/cmd/migrate/migrations/000013_create_scans.up.sql b/cmd/migrate/migrations/000012_add_scans.up.sql
similarity index 67%
rename from cmd/migrate/migrations/000013_create_scans.up.sql
rename to cmd/migrate/migrations/000012_add_scans.up.sql
index 2d3fb49f..88a48a89 100644
--- a/cmd/migrate/migrations/000013_create_scans.up.sql
+++ b/cmd/migrate/migrations/000012_add_scans.up.sql
@@ -6,10 +6,7 @@ CREATE TABLE IF NOT EXISTS scans (
scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- -- Each hacker can only be scanned once per type
UNIQUE(user_id, scan_type)
);
--- Fast aggregate: "how many people have claimed this type?"
-CREATE INDEX IF NOT EXISTS idx_scans_scan_type
- ON scans(scan_type);
+CREATE INDEX idx_scans_scan_type ON scans(scan_type);
diff --git a/cmd/migrate/migrations/000013_add_schedule.down.sql b/cmd/migrate/migrations/000013_add_schedule.down.sql
new file mode 100644
index 00000000..02dec4a5
--- /dev/null
+++ b/cmd/migrate/migrations/000013_add_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/migrations/000019_create_schedule.up.sql b/cmd/migrate/migrations/000013_add_schedule.up.sql
similarity index 74%
rename from cmd/migrate/migrations/000019_create_schedule.up.sql
rename to cmd/migrate/migrations/000013_add_schedule.up.sql
index b04bef38..b0134764 100644
--- a/cmd/migrate/migrations/000019_create_schedule.up.sql
+++ b/cmd/migrate/migrations/000013_add_schedule.up.sql
@@ -1,4 +1,4 @@
-CREATE TABLE schedule (
+CREATE TABLE IF NOT EXISTS schedule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@@ -10,8 +10,8 @@ CREATE TABLE schedule (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-CREATE TRIGGER set_updated_at_schedule
- BEFORE UPDATE ON schedule FOR EACH ROW
- EXECUTE FUNCTION set_updated_at();
+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/migrations/000014_add_scan_types_setting.down.sql b/cmd/migrate/migrations/000014_add_scan_types_setting.down.sql
deleted file mode 100644
index c4348b14..00000000
--- a/cmd/migrate/migrations/000014_add_scan_types_setting.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'scan_types';
diff --git a/cmd/migrate/migrations/000014_add_scan_types_setting.up.sql b/cmd/migrate/migrations/000014_add_scan_types_setting.up.sql
deleted file mode 100644
index acc79461..00000000
--- a/cmd/migrate/migrations/000014_add_scan_types_setting.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-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;
diff --git a/cmd/migrate/migrations/000014_add_sponsors.down.sql b/cmd/migrate/migrations/000014_add_sponsors.down.sql
new file mode 100644
index 00000000..bc2edf3f
--- /dev/null
+++ b/cmd/migrate/migrations/000014_add_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/migrations/000014_add_sponsors.up.sql b/cmd/migrate/migrations/000014_add_sponsors.up.sql
new file mode 100644
index 00000000..496794f9
--- /dev/null
+++ b/cmd/migrate/migrations/000014_add_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/cmd/migrate/migrations/000015_seed_scan_stats.down.sql b/cmd/migrate/migrations/000015_seed_scan_stats.down.sql
deleted file mode 100644
index f81a1054..00000000
--- a/cmd/migrate/migrations/000015_seed_scan_stats.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'scan_stats';
diff --git a/cmd/migrate/migrations/000015_seed_scan_stats.up.sql b/cmd/migrate/migrations/000015_seed_scan_stats.up.sql
deleted file mode 100644
index 65286108..00000000
--- a/cmd/migrate/migrations/000015_seed_scan_stats.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-INSERT INTO settings (key, value)
-VALUES ('scan_stats', '{}'::jsonb)
-ON CONFLICT (key) DO NOTHING;
diff --git a/cmd/migrate/migrations/000016_insert_review_assignment_setting.down.sql b/cmd/migrate/migrations/000016_insert_review_assignment_setting.down.sql
deleted file mode 100644
index dd3f88a0..00000000
--- a/cmd/migrate/migrations/000016_insert_review_assignment_setting.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'review_assignment_toggle';
diff --git a/cmd/migrate/migrations/000016_insert_review_assignment_setting.up.sql b/cmd/migrate/migrations/000016_insert_review_assignment_setting.up.sql
deleted file mode 100644
index b790d8b4..00000000
--- a/cmd/migrate/migrations/000016_insert_review_assignment_setting.up.sql
+++ /dev/null
@@ -1,14 +0,0 @@
-INSERT INTO settings (key, value)
-SELECT
- 'review_assignment_toggle',
- COALESCE(
- jsonb_agg(
- jsonb_build_object(
- 'id', u.id,
- 'enabled', true
- )
- ),
- '[]'::jsonb
- )
-FROM users u
-WHERE u.role = 'super_admin';
\ No newline at end of file
diff --git a/cmd/migrate/migrations/000017_add_ai_percent.down.sql b/cmd/migrate/migrations/000017_add_ai_percent.down.sql
deleted file mode 100644
index f7a2cad1..00000000
--- a/cmd/migrate/migrations/000017_add_ai_percent.down.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE applications
-DROP COLUMN ai_percent;
diff --git a/cmd/migrate/migrations/000017_add_ai_percent.up.sql b/cmd/migrate/migrations/000017_add_ai_percent.up.sql
deleted file mode 100644
index 35c19842..00000000
--- a/cmd/migrate/migrations/000017_add_ai_percent.up.sql
+++ /dev/null
@@ -1,6 +0,0 @@
-ALTER TABLE applications
-ADD COLUMN ai_percent SMALLINT DEFAULT NULL;
-
-ALTER TABLE applications
-ADD CONSTRAINT applications_ai_percent_check
-CHECK (ai_percent IS NULL OR (ai_percent >= 0 AND ai_percent <= 100));
diff --git a/cmd/migrate/migrations/000018_add_search_indexes.down.sql b/cmd/migrate/migrations/000018_add_search_indexes.down.sql
deleted file mode 100644
index 66e8f20d..00000000
--- a/cmd/migrate/migrations/000018_add_search_indexes.down.sql
+++ /dev/null
@@ -1,5 +0,0 @@
-DROP INDEX IF EXISTS idx_applications_last_name_trgm;
-DROP INDEX IF EXISTS idx_applications_first_name_trgm;
-DROP INDEX IF EXISTS idx_users_email_trgm;
-
-DROP EXTENSION IF EXISTS pg_trgm;
diff --git a/cmd/migrate/migrations/000018_add_search_indexes.up.sql b/cmd/migrate/migrations/000018_add_search_indexes.up.sql
deleted file mode 100644
index 1e27ac2c..00000000
--- a/cmd/migrate/migrations/000018_add_search_indexes.up.sql
+++ /dev/null
@@ -1,5 +0,0 @@
-CREATE EXTENSION IF NOT EXISTS pg_trgm;
-
-CREATE INDEX IF NOT EXISTS idx_users_email_trgm ON users USING gin (email gin_trgm_ops);
-CREATE INDEX IF NOT EXISTS idx_applications_first_name_trgm ON applications USING gin (first_name gin_trgm_ops);
-CREATE INDEX IF NOT EXISTS idx_applications_last_name_trgm ON applications USING gin (last_name gin_trgm_ops);
diff --git a/cmd/migrate/migrations/000019_create_schedule.down.sql b/cmd/migrate/migrations/000019_create_schedule.down.sql
deleted file mode 100644
index 8a5bf074..00000000
--- a/cmd/migrate/migrations/000019_create_schedule.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DROP TABLE IF EXISTS schedule;
diff --git a/cmd/migrate/migrations/000020_add_resume_path.down.sql b/cmd/migrate/migrations/000020_add_resume_path.down.sql
deleted file mode 100644
index 49f1856a..00000000
--- a/cmd/migrate/migrations/000020_add_resume_path.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE applications DROP COLUMN IF EXISTS resume_path;
diff --git a/cmd/migrate/migrations/000020_add_resume_path.up.sql b/cmd/migrate/migrations/000020_add_resume_path.up.sql
deleted file mode 100644
index a10244db..00000000
--- a/cmd/migrate/migrations/000020_add_resume_path.up.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE applications ADD COLUMN resume_path TEXT;
diff --git a/cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.down.sql b/cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.down.sql
deleted file mode 100644
index 32949062..00000000
--- a/cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'admin_schedule_edit_enabled';
diff --git a/cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.up.sql b/cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.up.sql
deleted file mode 100644
index b5331eee..00000000
--- a/cmd/migrate/migrations/000021_add_admin_schedule_edit_setting.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-INSERT INTO settings (key, value)
-VALUES ('admin_schedule_edit_enabled', 'true'::jsonb)
-ON CONFLICT (key) DO NOTHING;
diff --git a/cmd/migrate/migrations/000022_add_hackathon_date_range_setting.down.sql b/cmd/migrate/migrations/000022_add_hackathon_date_range_setting.down.sql
deleted file mode 100644
index d843985a..00000000
--- a/cmd/migrate/migrations/000022_add_hackathon_date_range_setting.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'hackathon_date_range';
diff --git a/cmd/migrate/migrations/000022_add_hackathon_date_range_setting.up.sql b/cmd/migrate/migrations/000022_add_hackathon_date_range_setting.up.sql
deleted file mode 100644
index 4623399b..00000000
--- a/cmd/migrate/migrations/000022_add_hackathon_date_range_setting.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-INSERT INTO settings (key, value)
-VALUES ('hackathon_date_range', '{"start_date": null, "end_date": null}'::jsonb)
-ON CONFLICT (key) DO NOTHING;
diff --git a/cmd/migrate/migrations/000023_create_sponsors.down.sql b/cmd/migrate/migrations/000023_create_sponsors.down.sql
deleted file mode 100644
index d60d4181..00000000
--- a/cmd/migrate/migrations/000023_create_sponsors.down.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-DROP TRIGGER IF EXISTS set_updated_at_sponsors ON sponsors;
-DROP TABLE sponsors;
diff --git a/cmd/migrate/migrations/000023_create_sponsors.up.sql b/cmd/migrate/migrations/000023_create_sponsors.up.sql
deleted file mode 100644
index fc0efc7d..00000000
--- a/cmd/migrate/migrations/000023_create_sponsors.up.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-CREATE TABLE sponsors (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- name TEXT NOT NULL,
- tier TEXT NOT NULL DEFAULT 'standard',
- logo_path 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 set_updated_at_sponsors
- BEFORE UPDATE ON sponsors FOR EACH ROW
- EXECUTE FUNCTION set_updated_at();
\ No newline at end of file
diff --git a/cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql b/cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql
deleted file mode 100644
index d79426fb..00000000
--- a/cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-ALTER TABLE sponsors ADD COLUMN logo_path TEXT NOT NULL DEFAULT '';
-ALTER TABLE sponsors DROP COLUMN logo_data;
-ALTER TABLE sponsors DROP COLUMN logo_content_type;
diff --git a/cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql b/cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql
deleted file mode 100644
index 7f321236..00000000
--- a/cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-ALTER TABLE sponsors ADD COLUMN logo_data TEXT NOT NULL DEFAULT '';
-ALTER TABLE sponsors ADD COLUMN logo_content_type TEXT NOT NULL DEFAULT '';
-ALTER TABLE sponsors DROP COLUMN logo_path;
diff --git a/cmd/migrate/migrations/000025_create_applications_enabled_setting.down.sql b/cmd/migrate/migrations/000025_create_applications_enabled_setting.down.sql
deleted file mode 100644
index 00ab13be..00000000
--- a/cmd/migrate/migrations/000025_create_applications_enabled_setting.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DELETE FROM settings WHERE key = 'applications_enabled';
diff --git a/cmd/migrate/migrations/000025_create_applications_enabled_setting.up.sql b/cmd/migrate/migrations/000025_create_applications_enabled_setting.up.sql
deleted file mode 100644
index ee3eec9d..00000000
--- a/cmd/migrate/migrations/000025_create_applications_enabled_setting.up.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-INSERT INTO settings (key, value) VALUES ('applications_enabled', 'true'::jsonb)
-ON CONFLICT (key) DO NOTHING;
diff --git a/docs/docs.go b/docs/docs.go
index 103d7506..b6525316 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,47 @@ 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 +3905,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 +3919,6 @@ const docTemplate = `{
},
"waitlist_votes": {
"type": "integer"
- },
- "website": {
- "type": "string"
}
}
},
@@ -4367,17 +4288,6 @@ const docTemplate = `{
}
}
},
- "main.ShortAnswerQuestionsResponse": {
- "type": "object",
- "properties": {
- "questions": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/store.ShortAnswerQuestion"
- }
- }
- }
- },
"main.SponsorListResponse": {
"type": "object",
"properties": {
@@ -4445,6 +4355,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 +4436,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 +4479,24 @@ 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 +4506,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 +4520,6 @@ const docTemplate = `{
},
"waitlist_votes": {
"type": "integer"
- },
- "website": {
- "type": "string"
}
}
},
@@ -4730,7 +4550,7 @@ const docTemplate = `{
"gender": {
"type": "string"
},
- "hackathons_attended_count": {
+ "hackathons_attended": {
"type": "integer"
},
"has_resume": {
@@ -4748,7 +4568,7 @@ const docTemplate = `{
"major": {
"type": "string"
},
- "phone_e164": {
+ "phone": {
"type": "string"
},
"reject_votes": {
@@ -4860,7 +4680,7 @@ const docTemplate = `{
"description": "Application fields",
"type": "string"
},
- "hackathons_attended_count": {
+ "hackathons_attended": {
"type": "integer"
},
"id": {
@@ -4889,6 +4709,45 @@ 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"
+ },
+ "section_label": {
+ "type": "string"
+ },
+ "section_order": {
+ "type": "integer"
+ },
+ "type": {
+ "type": "string"
+ },
+ "validation": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
"store.ApplicationStats": {
"type": "object",
"properties": {
@@ -5101,32 +4960,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": {
diff --git a/internal/db/seed.go b/internal/db/seed.go
index d20cbb78..a83786e7 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"
@@ -43,20 +44,13 @@ var (
"Data Science", "Mathematics", "Information Technology", "Cybersecurity",
"Mechanical Engineering", "Physics", "Business Analytics",
}
- levels = []string{"Freshman", "Sophomore", "Junior", "Senior", "Masters", "PhD"}
- genders = []string{"Male", "Female", "Non-binary", "Prefer not to say"}
- shirtSizes = []string{"XS", "S", "M", "L", "XL", "XXL"}
- 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."
- }`
+ levels = []string{"Freshman", "Sophomore", "Junior", "Senior", "Masters", "PhD"}
+ genders = []string{"Male", "Female", "Non-binary", "Prefer not to say"}
+ shirtSizes = []string{"XS", "S", "M", "L", "XL", "XXL"}
+ 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"}
+ dietaryOptions = []string{"Vegan", "Vegetarian", "Halal", "Nuts", "Fish", "Wheat", "Dairy", "Eggs", "No Beef", "No Pork"}
reviewNotePool = []string{
"Strong technical background, good fit.",
@@ -139,32 +133,29 @@ 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,
+ submitted_at
+ ) VALUES ($1, $2, $3, $4)
+ RETURNING id
`
for i, userID := range hackerIDs {
@@ -179,18 +170,42 @@ 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.",
+ "ack_mlh_coc": submitted,
+ "ack_mlh_privacy": submitted,
+ "opt_in_mlh_emails": rng.Intn(2) == 0,
+ }
+
+ 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,
submittedAt,
).Scan(&id)
if err != nil {
diff --git a/internal/store/applications.go b/internal/store/applications.go
index f310af7c..0f569a84 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 (
@@ -70,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
@@ -119,30 +55,30 @@ type ApplicationListFilters struct {
// ApplicationListItem is a lightweight view for admin listing
type ApplicationListItem struct {
- ID string `json:"id"`
- UserID string `json:"user_id"`
- Email string `json:"email"`
- Status ApplicationStatus `json:"status"`
- FirstName *string `json:"first_name"`
- LastName *string `json:"last_name"`
- PhoneE164 *string `json:"phone_e164"`
- 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"`
- 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 *int `json:"ai_percent"`
- HasResume bool `json:"has_resume"`
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+ Email string `json:"email"`
+ Status ApplicationStatus `json:"status"`
+ FirstName *string `json:"first_name"`
+ LastName *string `json:"last_name"`
+ 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"`
+ HackathonsAttended *int16 `json:"hackathons_attended"`
+ 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 *int `json:"ai_percent"`
+ HasResume bool `json:"has_resume"`
}
// ApplicationListResult contains paginated results
@@ -203,43 +139,9 @@ 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"`
-
- 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"`
+ Responses json.RawMessage `json:"responses"`
+ ResumePath *string `json:"resume_path"`
+ AIPercent *int16 `json:"ai_percent"`
AcceptVotes int `json:"accept_votes"`
RejectVotes int `json:"reject_votes"`
@@ -247,47 +149,38 @@ type Application struct {
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,
+ 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.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
@@ -302,36 +195,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
@@ -349,14 +216,11 @@ 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,
- created_at, updated_at
+ RETURNING id, status, responses, 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.CreatedAt, &app.UpdatedAt,
)
if err != nil {
@@ -375,47 +239,15 @@ 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
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,
).Scan(&app.UpdatedAt)
if err != nil {
@@ -522,10 +354,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' 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,
+ 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
@@ -534,8 +372,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
@@ -617,10 +455,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,
@@ -696,33 +534,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
@@ -783,7 +598,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
diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go
index 576e7b42..0469a60e 100644
--- a/internal/store/mock_store.go
+++ b/internal/store/mock_store.go
@@ -150,16 +150,16 @@ type MockSettingsStore struct {
mock.Mock
}
-func (m *MockSettingsStore) GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error) {
+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).([]ShortAnswerQuestion), args.Error(1)
+ return args.Get(0).([]ApplicationSchemaField), args.Error(1)
}
-func (m *MockSettingsStore) UpdateShortAnswerQuestions(ctx context.Context, questions []ShortAnswerQuestion) error {
- args := m.Called(questions)
+func (m *MockSettingsStore) UpdateApplicationSchema(ctx context.Context, fields []ApplicationSchemaField) error {
+ args := m.Called(fields)
return args.Error(0)
}
diff --git a/internal/store/reviews.go b/internal/store/reviews.go
index c3dad089..6e5a08f6 100644
--- a/internal/store/reviews.go
+++ b/internal/store/reviews.go
@@ -34,14 +34,14 @@ type ApplicationReview struct {
type ApplicationReviewWithDetails struct {
ApplicationReview
// Application fields
- FirstName *string `json:"first_name"`
- LastName *string `json:"last_name"`
- Email string `json:"email"`
- Age *int16 `json:"age"`
- University *string `json:"university"`
- Major *string `json:"major"`
- CountryOfResidence *string `json:"country_of_residence"`
- HackathonsAttendedCount *int16 `json:"hackathons_attended_count"`
+ FirstName *string `json:"first_name"`
+ LastName *string `json:"last_name"`
+ Email string `json:"email"`
+ Age *int16 `json:"age"`
+ University *string `json:"university"`
+ Major *string `json:"major"`
+ CountryOfResidence *string `json:"country_of_residence"`
+ HackathonsAttended *int16 `json:"hackathons_attended"`
}
// ReviewNote represents a note from an admin review (without vote information)
@@ -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,
+ NULLIF(a.responses->>'age', '')::smallint,
+ a.responses->>'university', a.responses->>'major',
+ a.responses->>'country_of_residence',
+ 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
@@ -120,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
}
@@ -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,
+ NULLIF(a.responses->>'age', '')::smallint,
+ a.responses->>'university', a.responses->>'major',
+ a.responses->>'country_of_residence',
+ 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
@@ -168,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/schedule.go b/internal/store/schedule.go
index 2015183b..507c751f 100644
--- a/internal/store/schedule.go
+++ b/internal/store/schedule.go
@@ -3,10 +3,61 @@ package store
import (
"context"
"database/sql"
+ "database/sql/driver"
"errors"
+ "fmt"
+ "strings"
"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 ScheduleItem struct {
ID string `json:"id"`
EventName string `json:"event_name"`
diff --git a/internal/store/settings.go b/internal/store/settings.go
index c0cb8b57..52e79564 100644
--- a/internal/store/settings.go
+++ b/internal/store/settings.go
@@ -7,20 +7,12 @@ 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"
const SettingsKeyScanTypes = "scan_types"
@@ -34,6 +26,21 @@ 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"`
+ SectionLabel string `json:"section_label,omitempty"`
+ SectionOrder int `json:"section_order"`
+ 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 {
@@ -41,8 +48,8 @@ type ReviewAssignmentEntry struct {
Enabled bool `json:"enabled"`
}
-// GetShortAnswerQuestions returns the parsed questions array
-func (s *SettingsStore) GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error) {
+// 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()
@@ -53,20 +60,40 @@ func (s *SettingsStore) GetShortAnswerQuestions(ctx context.Context) ([]ShortAns
`
var value []byte
- err := s.db.QueryRowContext(ctx, query, SettingsKeyShortAnswerQuestions).Scan(&value)
+ err := s.db.QueryRowContext(ctx, query, SettingsKeyApplicationSchema).Scan(&value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
- return []ShortAnswerQuestion{}, nil
+ return []ApplicationSchemaField{}, nil
}
return nil, err
}
- var questions []ShortAnswerQuestion
- if err := json.Unmarshal(value, &questions); err != nil {
+ var fields []ApplicationSchemaField
+ if err := json.Unmarshal(value, &fields); err != nil {
return nil, err
}
- return questions, nil
+ 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, updated_at = NOW()
+ `
+
+ _, err = s.db.ExecContext(ctx, query, SettingsKeyApplicationSchema, string(value))
+ return err
}
// GetReviewsPerApplication returns the configured number of reviews per application
@@ -158,7 +185,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)
@@ -230,26 +257,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 802988b6..7b396d48 100644
--- a/internal/store/storage.go
+++ b/internal/store/storage.go
@@ -39,8 +39,8 @@ 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)
SetReviewsPerApplication(ctx context.Context, value int) error
GetAllReviewAssignmentToggles(ctx context.Context) ([]ReviewAssignmentEntry, error)
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